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

Architecture Decision Record

Architecture Decision Records

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 from the code.

# Decision Summary
001 Manual DTO mapping Per-entity Mapperly source-generated mappers chosen over AutoMapper-style runtime reflection.
002 Navigation populators INavigationPopulator<T> for cross-container/cross-source eager loading.
003 Outbox dual dispatch Outbox + in-process dispatch + background processor; at-least-once delivery. Revised 2026-07-19: integration events route through the outbox to IMessageBus (never local dispatch), lease columns (LockedUntil/LockToken) make replica scale-out safe by construction, and retry exhaustion is dead-lettered loudly (metric + Error log + DeadLetterRetentionDays). Revised 2026-08-07: the exponential retry backoff is jittered (0.8-1.2x per wait, capped at the lease) so a batch that fails together does not retry in lockstep. Revised 2026-08-26: opt-in per-key ordered delivery via IHasOrderingKey, enforced inside the claim update as a correlated NOT EXISTS (so ordering survives batching and scale-out, with explicit head-of-line blocking and a dead-lettered row that stops blocking, and an unkeyed batch running exactly the query it always ran); dead letters are swept on their own retention window (Outbox:DeadLetterRetentionDays, falling back to RetentionDays, with every deletion logged and counted per source) and gain a way back through IOutboxAdministration list/replay/count, whose replay resets the retry count and lease but deliberately keeps LastError and OccurredOn; an outbox.oldest_pending.age gauge reports how late the stuck backlog already is, computed free from the poll's first row; [EventName] is the one stable-identity mechanism that carries a renamed or relocated event type across the wire (moved contracts only, reshaped ones need ADR-090's upcasters) and the first unresolvable attempt is retried once before dead-lettering; and the consumer-side inbox (ADR-021) resolves ON by default for broker transports, with its row staged into the handler's own unit of work so it commits in the same transaction as the handler's mutations. Amended by ADR-100 (2026-08-29): the outbox itself becomes transport-resolved (MessageBus:EnableOutbox as bool?, ON for a broker and OFF for in-process), a broker with it explicitly disabled throws at registration, and the OutboxMessages table stays mapped so the flag is never a migration.
004 Cross-service token validation (JWKS) Extracted services validate Identity's RS256 tokens via JWKS / OIDC discovery (no shared key); RS256 is the default because it survives extraction, and HS256 shared-secret is the explicit single-process monolith option; discovery is gateway-routed with a direct-Identity fallback.
005 Soft-delete vs. erasure Soft-delete stays for lifecycle; IAnonymizable + outbox purge for GDPR/CCPA erasure.
006 Database per service Each service owns its DB + outbox; one sealed context class per engine over the abstract base, one instance per DB. Removed the shared-outbox race (2026-06-07). Revised 2026-09-03: the legacy AtlDevCon database was exported to sql-archive/AtlDevCon-20260902.bacpac and dropped on 2026-09-02; the four ADC_* databases are the whole estate and the bacpac is the sole rollback source.
007 gRPC cross-service calls *.Contracts + typed clients + Result-over-the-wire for synchronous inter-service calls. Revised 2026-09-04: the *.Contracts adapter is named as the module's Anti-Corruption Layer (no code change).
008 Monolith → services + Gateway One service host per module (the monolith with one module enabled), fronted by a YARP Gateway; transport at the edge keeps it reversible. Amended by ADR-089 (2026-08-18): the route-to-service map moves out of MapForwarder code into YARP ReverseProxy configuration, pinned by a route-map test; the topology itself is unchanged. ADR-088 (2026-08-18) adds the first cross-cutting behavior the Gateway has gained since this record. Amended 2026-08-28: the record now states its own driver plainly. ADC's four service hosts exist first of all to demonstrate and continuously exercise the extraction path end to end (the framework's core promise), not because ADC hit a scale, team or deploy-cadence trigger: the conference peaked at roughly 67 concurrent users, one team owns every module, and all six deployables still ship in a single pipeline run from one Bicep template, so the independent-deploy benefit is available rather than taken. A consumer should extract on an observable constraint and stay a modular monolith until then; ADC deliberately runs ahead of its own. Revised 2026-09-03: the Gateway has no route forwarders; routes come from YARP configuration through AddMmcaGateway (ADR-089), Store's Gateway additionally layers Key Vault configuration, and AddServiceDefaults ships in MMCA.Common.Aspire (there is no ServiceDefaults project). Revised 2026-09-04: the extraction sequence is named as the Strangler Fig route (new service beside the combined host, one route prefix moved at the Gateway at a time, old host retired last); ADC's own history was a one-step cutover, recorded as such.
009 Resilience & recovery objectives Standard resilience handler on every outbound client (fitness-enforced); consumers must declare RTO/RPO + drilled restore + single-region acceptance. Amended by ADR-087 (2026-08-18): the objective extends past outbound HTTP/gRPC clients for the first time, to the outbox's broker publish, which gains a circuit breaker; the database posture is unchanged and a per-query DB breaker is recorded as rejected (it does not compose with EF's execution strategy).
010 Integration-event schema versioning Every integration event carries a SchemaVersion (default 1, fitness-enforced); breaking changes use a new event type + upcaster, never a silent reshape. Completed by ADR-090 (2026-08-21): the upcaster registration extension point this record named as follow-up work now ships in the framework.
011 Single-locale by design (no i18n) en-US only is a deliberate, revisitable non-goal Superseded by ADR-027.
012 gRPC-host transport convention Two coherent profiles; the Kestrel choice forces the gateway-forward mode + JWKS routing. Both consumers default to Profile A (Http2-only h2c) after Store converged on 2026-06-22; Profile B (Http1AndHttp2 + ALPN) is retained only for the SignalR/WebSocket hosts (ADC's Notification service and Store's Sales service). Since 2026-07-09 ADC's Notification runs a mixed-endpoint profile: Http1AndHttp2 default endpoint plus a dedicated Http2-only named grpc endpoint for the ADR-039 live-channel push ingress. Corrected 2026-07-25: gateway-routed JWKS is the local Aspire wiring only, both consumers inject the direct in-cluster Identity authority in production; and ADC hosts get a dedicated Http1-only probe listener. The TCP-probe arrangement once recorded for Store's Http2-only hosts was retired by this record's own 2026-07-28 update: no tcpSocket probe remains in either consumer's Bicep template. Updated 2026-08-07: the per-service Kestrel wiring is now one shared framework method, ConfigureEndpointsWithHealthProbe in MMCA.Common.Aspire (redeclareCleartextEndpoint: false expresses the mixed profile), and ADC's Notification maps a second authorized gRPC service (user-notification export) on the same grpc endpoint. Updated 2026-08-14: Store's Sales also runs the mixed-endpoint profile through the same shared helper (plus an authorized user-sales-export gRPC service), so no deployed host is pure Profile B any longer. Re-verified 2026-08-31: anchors re-pinned across both apps, and ADC Notification's local dev ports moved below the OS ephemeral range (http 5998 / https 5997 / grpc 5996).
013 Result pattern over exceptions Expected failures are Result/Result<T> values with a transport-agnostic ErrorType; only the edge maps to HTTP/gRPC. Exceptions stay for the genuinely exceptional, and the exceptional path has its own edge contract: an ordered IExceptionHandler chain (OperationCanceled, Domain, DbUpdate, Validation, Global; registration order load-bearing) emitting RFC 9457 ProblemDetails (revised 2026-07-21). Revised 2026-08-26: a ninth category, ErrorType.Unexpected, maps to HTTP 500 and gRPC Internal and is reserved for faults a caller cannot fix by changing the request; the HTTP status of a multi-error failure is now taken from the most severe category present (Unexpected > Unauthorized > Forbidden > Conflict > NotFound > UnprocessableEntity > Invariant/Validation/Failure, ties keeping the earliest error and an unmapped category ranking lowest) rather than from the first error, since Result.Combine aggregates in evaluation order and an incidental validation failure could downgrade a real 403 or 500 to a 400; and the combinator surface is recorded in full: implicit Error to Result/Result<T> and value to Result<T> conversions, Match/MatchAsync/Map/Bind/BindAsync/Tap/Ensure on Result<T>, Match/Bind/OnFailure on the non-generic Result, and ResultExtensions carrying the same combinators over a pending Task<Result<T>>. Revised 2026-08-27 (v1.164.0): the UI layer deviation is retired. Every HTTP-typed client service in MMCA.Common.UI returns Result end to end instead of rethrowing the server's message as a DomainInvariantViolationException, and ServiceExceptionHelper is deleted rather than deprecated; ProblemDetailsResultReader (in Shared, so the UI can reference it) converts the response, parsing an RFC 9457 body back into typed errors with the original ErrorType preserved on the MMCA error-array shape, while HttpResultExecutor converts the absence of one (connection, DNS, socket, client timeout) into Http.TransportFailure / Http.Timeout. OperationCanceledException is the one exception still crossing the boundary, deliberately: a page owns its own cancellation and must not have it reported back as an error to render. Pages branch rather than catch, through ResultUiExtensions (TryGetValue, OnFailureSetError, NotifyOnFailure, HasErrorType/IsNotFound/IsUnauthorized, all localizing messages as resource keys with pass-through and ordering them by severity) and the shared deduplicating ErrorSummary component; MobileInfiniteScrollList takes a single FetchPageResult delegate so a failure keeps its inline retry with the server's own wording. The severity ranking is hoisted into MMCA.Common.Shared as ErrorTypeSeverity, so the HTTP and gRPC edges now classify one aggregate identically (the gRPC first-error pick is gone); the residual loss is the reverse mapping, which is lossy on 400 alone because the forward map collapses Validation/Invariant/Failure onto it and the reverse can only pick Validation, so a client needing the distinction must consume an endpoint that emits the error array. Revised 2026-08-31: the ordered exception-handler chain ends in a catch-all that answers HTTP 400 for a tenant-boundary write rejection (CrossTenantWriteException) before falling back to 500. Revised 2026-09-03: the error Code is a public vocabulary gated for uniqueness, a Mono.Cecil IL scan over each module's Domain and Application assemblies (ErrorCatalogTestsBase, subclassed by ADC and Store) fails the build when the same literal code is constructed by more than one declaring type or carries a prefix outside the allowed set.
014 CQRS decorator pipeline Thin ICommandHandler/IQueryHandler use cases behind a Scrutor decorator chain; the order is load-bearing. Revised 2026-07-19: business failures (Result.Failure) now roll the transaction back like exceptions, and in-process event dispatch is deferred until after commit. Revised 2026-08-18: the order changed, to FeatureGate -> Authorization -> Logging -> Caching -> Validating -> Timeout -> Transactional -> Handler (queries: FeatureGate -> Authorization -> Logging -> Caching -> Timeout -> Handler). Authorization is keyed on IRequiresPermission and resolves through IPermissionRegistry + ICurrentUserService.Roles, returning a Forbidden error and counting cqrs.authorization.denied.count; it sits outside caching deliberately, so a denied request neither reads nor populates the cache. Timeout is keyed on IHasTimeout, links a budget token to the caller's, fails with code Request.TimedOut and counts cqrs.timeout.count, while caller cancellation still propagates through an exception filter. The order is now pinned by the shipped DecoratorPipelineOrderTestsBase rather than by comments alone. Revised 2026-08-26: a Validating decorator joins the query chain (FeatureGate -> Authorization -> Logging -> Caching -> Validating -> Timeout -> Handler), placed inside Caching on purpose (a cached entry was already validated when it was first produced, so re-validating a hit reaches a conclusion already reached) and outside Timeout (a caller is not charged budget for validating its own bad input), so paging/filter/sort input is rejected instead of pushed into the data source. The decorators-last rule stops being a convention whose violation is silent: AddMmcaApplicationPipeline(pipeline => ...) runs AddApplication -> handler registrations -> AddApplicationDecorators and seals the collection, so a later handler-registering call throws instead of leaving that handler unwrapped, and VerifyDecoratorPipeline() gives fitness tests a registration-shape-only coverage check (no service provider built) that names every unwrapped handler. Revised 2026-08-31: both Validating decorators run every registered validator in registration order and union their failures into one Result rather than stopping at the first; and the sealed AddMmcaApplicationPipeline(...) composition path is the shipped idiom across all seven production service hosts, with Helpdesk's web host still ending in a bare AddApplicationDecorators().
015 Architecture fitness functions Invariants gate the build twice: a compile-time layer guard (MSBuild) + a shared NetArchTest rule library parameterized by IArchitectureMap, run identically across all four repos (Common / Store / ADC / Helpdesk). Revised 2026-08-18: two new rule families plus a third enforcement layer. NamespacesHaveNoDependencyCycles finds strongly connected components in each layer assembly's namespace graph from the type signature surface, with the AllowedCycleNamespaces hook checked against the whole component so an allowance cannot hide a new cycle (Common exempts one Infrastructure tangle, root -> Settings -> Persistence -> root, justified per edge). AsyncMethodsDeclareTrailingCancellationToken requires public Task/ValueTask methods in Application and Infrastructure to end in CancellationToken cancellationToken, with automatic exemptions for Dispose/DisposeAsync, compiler-generated and special-name members, and overrides or implementations of externally-declared signatures; the two real findings (NotificationHub.JoinChannelAsync/LeaveChannelAsync) are exempted as SignalR wire contracts but were made cancellable via Context.ConnectionAborted. The third layer is a compile-time public API surface gate (Microsoft.CodeAnalysis.PublicApiAnalyzers 5.6.0 wired once in Directory.Build.props for 14 of the 15 Source projects, UI.Maui documented as excluded on build-topology grounds): RS0016/RS0017 over PublicAPI.Shipped/Unshipped baselines holding 5,150 declarations, errors by inheritance from the repo's global analyzer-error default rather than by their own entry, so widening or breaking a package surface becomes a reviewable text diff; RS0026/RS0027/RS0041 off with recorded reasons. The baseline shipped in v1.153.0, so the gate is live for consumers from that release on. Formalizes what the consumer-source-build canary only sampled. All three layers run in the required build-and-test gate. Revised again 2026-08-18 (Section B): two further consumer-facing rule families. ArchitectureRules.Protos gives the gRPC wire contract the freeze integration events already had, pinning the package, every rpc with both streaming flags, every field with its number and every enum value, while deliberately leaving syntax, import and every option unpinned (the line is "would a deployed peer notice", so a csharp_namespace change passes the gate even though it breaks generated client code); MMCA.Common ships no .proto and exercises the rule through a matched clean/drifted fixture pair instead. The idempotency-intent gate (see ADR-017) joins beside it. Counts are owned by FACTS.md and this record stops restating them: as of framework v1.160.0 it reads 110 test methods across 38 abstract *TestsBase classes (FACTS.md:44), of which MMCA.Common's own build executes 129 (FACTS.md:47), superseding both the 102/34/87 and the 104/36/99 figures above. The executed figure now exceeds the shipped method count because it also counts Common's own direct tests, so the two are no longer a subset and its container.
016 Lockstep versioning + MassTransit-v8 pin All packages release at one version (count owned by FACTS.md); consumers swept in one pass (no phased rollout). MassTransit is pinned to v8 (v9 needs a license) and the pin is a fitness-function build gate. Amended 2026-08-28: a Transport exit options section gives the pin its horizon (v8 community support ends at the end of 2026) and records three candidates, none adopted and none evaluated against a running broker here: the OpenTransit community fork of v8 (a package id swap on paper, with Azure Service Bus parity as the deciding unknown and its status an external fact this record does not assert), a commercial v9 license (retires the gate rather than routing around it, at a recurring cost), and a direct Azure.Messaging.ServiceBus implementation of IMessageBus (publishing is one class, but three IConsumer<T> consumers plus the retry and delayed-redelivery wiring would be hand-written, and RabbitMQ is dropped). What bounds the move is that the whole using MassTransit surface is six files, all inside MMCA.Common.Infrastructure, with Application/Domain/Shared holding only IMessageBus; the trial point is an additive case in the MessageBusProvider switch inside ConfigureBrokerTransport, so a candidate ships beside RabbitMQ and Service Bus, and ADC's advisory nightly Service Bus emulator smoke is somewhere to exercise it that is not production. Amended 2026-08-31: the commercial-license pin is a family rather than a single package (MassTransit below v9, ImageSharp below v4), enforced by the same fitness function and mirrored as dependabot ignores; Common, Store and ADC all sit on MassTransit 8.5.10 today, though only the major is gated.
017 HTTP request idempotency [Idempotent] action filter dedups client retries via an Idempotency-Key header + cached replay (24h, X-Idempotent-Replay); distinct from ADR-003's handler idempotency. Revised 2026-08-18: the opt-in is no longer silent. [NonIdempotent(justification)] joins [Idempotent] and a fitness gate (IdempotencyConventionTestsBase, inherit-aware, concrete classes only) fails any [HttpPost] declaring neither, converting the audit-the-inventory trade-off into a declared decision. AuthControllerBase marks register [Idempotent] and login/refresh/revoke plus the OAuth exchange [NonIdempotent], because ADR-050 rotates the refresh token on every use, so a replayed token response would hand back a pair already revoked. The no-op-without-a-key contract is now pinned by tests, which is what makes annotating an existing endpoint invisible to existing clients. Scope limits recorded: POST only (PUT/PATCH are outside the gate), and the justification string is required to exist, not to be a reason.
018 Polyglot persistence (per-engine sources) Three storage engines (SQL Server / Cosmos / SQLite) behind one model; engine is a [UseDataSource] attribute on the entity config (the orthogonal Engine axis to ADR-006's Name axis). Plumbing shipped + tested; first non-SQL entity not yet in production. Revised 2026-08-29: engines still never collapse into each other except when the requested one is configured nowhere, in which case the resolver serves it from the sole configured engine (relational preferred, SQL Server ahead of SQLite, announced once at startup), so a SQLite-only host stops handing the outbox, scheduler, audit trail, refresh sessions and transaction coordination an empty connection string; SQL-Server-only and genuinely polyglot hosts are unaffected, and the [Required] SQL Server connection string is replaced by a validator that accepts any engine, top-level or on a named entry, while still failing a host with no database anywhere.
019 Layered rate limiting (authenticated-only global limiter) An always-on global limiter caps authenticated callers per-user (default 300/min) and exempts infra (/health, /alive, /.well-known, gRPC) and anonymous traffic; the anonymous auth endpoints get the per-IP auth-ip cap (default 30/min) that AuthControllerBase applies to login/register by default, public reads lean on output caching plus the login-protection service, and FixedPolicy/UserPolicy stay opt-in (nothing applies them). Amended 2026-08-01: the forwarded-header trust posture (KnownProxies/KnownIPNetworks cleared, ForwardLimit at its default of 1) is recorded as the decided edge trust boundary with its spoofing trade-off. Revised 2026-08-18: the hard-coded figures become a bound, [Range]-validated RateLimitingSettings (section RateLimiting); Algorithm selects FixedWindow (default) or SlidingWindow with SegmentsPerWindow (default 4) over the same one-minute window; and Distributed swaps the global and UserPolicy partitions onto a Redis-backed fixed-window limiter (INCR plus a 65-second EXPIRE on its own rl: keyspace) that fails open on any Redis fault and silently degrades to in-memory when no multiplexer is registered. auth-ip and FixedPolicy stay deliberately local, so the per-replica multiplication trade-off is narrowed, not removed.
020 Permission-based authorization over roles A capability layer over RBAC: [HasPermission("…")] resolves to on-demand perm:* policies backed by a central role→permission IPermissionRegistry; modules declare grants additively via AddPermissions. Permission policies are the one authorization model the framework registers: it pre-registers no named role policy of its own, and any non-perm: name a host registers itself still resolves through the default provider. Inert until a host grants. Adopted by ADC (Conference/Identity/Engagement), by Store (Catalog/Identity/Sales), and by the framework's own notification endpoints (notifications:manage).
021 Consumer-side inbox idempotency Opt-in inbox (IInboxStore / EfInboxStore, MessageBus:EnableInbox) dedups broker redeliveries by MessageId: IntegrationEventConsumer checks before handlers, records after success, in the consumer's own DB (unique index as the race guard). At-least-once-with-dedup (handlers stay idempotent for the crash window). The broker-consume sibling of ADR-003 / ADR-017. Revised 2026-08-18: still opt-in, but no longer silently off. A broker-connected host (the InProcess provider returns early, so this is exactly the hosts that can be redelivered to) that lands on NoOpInboxStore registers InboxDisabledWarningService, which logs one startup Warning naming the consequence and the fix; MessageBus:EnableInbox=true is now the documented recommendation for any such host. The InboxMessages entity is configured unconditionally in ApplicationDbContext.OnModelCreating (relational engines only; Cosmos does not call the base), so for every service in this workspace enabling the flag is a config change and a restart, with no migration. Revised 2026-08-26: the opt-in is gone where redelivery is possible. MessageBus:EnableInbox becomes bool? and IsInboxEnabled resolves an unset value from the transport (ON for RabbitMQ and Azure Service Bus, OFF for in-process, which has no redelivery to dedup), an explicit value still winning in both directions, so reaching NoOpInboxStore now means a deliberate opt-out and the startup Warning says so; "a broker-consuming service that forgets the flag gets no dedup" stops being a reachable state. The row also stops being a separate write: TryBeginAsync STAGES the InboxMessage into the same scoped context the handlers write through, so a handler's own SaveChangesAsync commits it in the same transaction as its mutations and the crash-after-handler-before-inbox window closes for it (it stays open for a handler that writes nothing or writes to a different physical source, where CompleteAsync still writes the row afterwards). The failure path calls Abandon, which detaches a still-Added row before the rethrow that triggers MassTransit's retry, and warns loudly in the one case this design loses to a pure after-the-fact inbox: a handler that already committed the row before a later handler failed, whose redelivery is then skipped as a duplicate. Unique-index duplicate absorption is preserved on both saves, by re-query rather than by provider error codes. The same three-valued resolution reaches the producer side in ADR-100 (2026-08-29), so EnableInbox and EnableOutbox now read identically; nothing about the inbox contract changes.
022 Browser session-cookie auth (Blazor SSR) HttpOnly mmca_auth_access / mmca_auth_refresh cookies carry the session; SessionCookieAuthenticationHandler reads claims during SSR prerender (no signature check: the API stays the boundary, ADR-004) so [Authorize] passes on fresh GETs; refresh token stays server-side, hydrated via /auth/session/token. BFF-style, SameSite=Lax with a Sec-Fetch-Site check on the refresh endpoint.
023 Security-response headers + pluggable CSP Centralized hardened security-headers middleware (AddCommonSecurityHeaders) with an ICspPolicyProvider CSP extension point; the baseline CSP omits script-src/style-src so it cannot break Blazor, and HTML hosts register their own policy; both apps' UI hosts use the single shared BlazorCspPolicyProvider (in MMCA.Common.UI.Web, via AddCommonBlazorCsp). Adopted at both apps' Gateway + UI edges.
024 Two-channel user notifications One application use case writes a durable per-user UserNotification inbox (read/unread) and fires a transient SignalR push; transport is behind IPushNotificationSender (no-op Null default, swapped by AddPushNotifications with an optional Redis backplane) and audience behind INotificationRecipientProvider. Push failure is non-fatal (the inbox is the source of truth). ADR-044 adds an optional third OS-level native-push leg after these two, and the hub also carries ADR-039's ephemeral live-channel events. ADC runs a dedicated Notification service on these abstractions. Revised 2026-08-07: transactional email (IEmailSender, framework-registered SMTP primitive) is recorded as a direct-send primitive outside this channel model. Revised 2026-08-31: the durable inbox row is written before the best-effort push leg fires, so the ordering matches the source-of-truth claim, and the email primitive is consumed by the framework's own password-reset workflow (ADR-091) as well as by app code (Store's Sales handlers).
025 Startup warm-up + readiness gating WarmupHostedService runs IWarmupTasks once in parallel at startup and a ready-tagged WarmupReadinessGate holds /health/ready unhealthy so probes keep traffic off a warming replica; the gate opens even if a task fails (availability over warmth, lazy retry under ADR-009). Built-in task pre-fetches OIDC discovery to kill ACA cold-start. Wired into AddServiceDefaults, so every host gets it. Revised 2026-08-31: the gate also opens on a 120-second per-task timeout, and the warm-up subsystem ships SelfHttpWarmupTaskBase beside the discovery pre-fetch, the self-HTTP base class both production apps subclass to warm their own inbound request path. Revised 2026-09-02: a readiness check may only issue a PING-class command (Redis PING, SELECT 1), never an admin-mode or cluster-topology one, and MMCA.Common owns the Redis registration (AddRedisCaching) so the untagged health check an Aspire client integration adds cannot gate readiness.
026 Two-tier caching (swappable substrate + output-cache edge) One ICacheService abstraction whose backing store is chosen at startup (AddCaching picks DistributedCacheService when Aspire registered Redis, else MemoryCacheService) with prefix invalidation and a 30s default TTL; a second HTTP output-cache tier (UseOutputCache, per-host policies) serves public reads at the edge (since ADR-040, authenticated requests too on registered public endpoints). Distinct from ADR-014's caching decorators (which consume this substrate). Every service now registers AddRedisClient alongside the distributed cache, so SCAN-based prefix invalidation is live whenever Redis is configured; the TTL backstops only the no-Redis case (amended 2026-07-23). Amended 2026-07-25: the output-cache store itself is Redis-backed wherever Redis is configured (ADR-040), and IncrementAsync is deliberately a read-modify-write on every backing store, not a Redis INCR, because INCR writes a string while the cache stores hashes. Amended by ADR-077 (2026-08-13): an opt-in HybridCacheService (L1 + L2) joins the two auto-selected Tier 1 stores, on a disjoint hc: keyspace. Revised 2026-08-18: Tier 2 stops being per-process, the first change to the output-cache edge since ADR-040. OutputCacheEvictionRequested (the framework's first shipped integration event; Tags defaults to empty so a malformed message evicts nothing rather than everything) rides the existing outbox, broker and inbox path so a mutation in one service can evict another service's output cache, per tag and best-effort, counting cache.eviction.failed on a new MMCA.Common.OutputCache meter. Two registrations in two packages (RegisterOutputCacheEvictionConsumer in Infrastructure, AddOutputCacheEvictionHandler in API) and a host that calls only one gets silence. Tier 1 is untouched, the mirror image of ADR-077's Tier-1-only amendment. Amended 2026-09-01: an optional third tier on the client, IUiReadCache, a per-circuit read-through cache over the API client (keys are the relative URL, path plus full query, so they match Tier 2's QueryKeys = "*"; successes only, prefix invalidation on write, cleared on sign-out, TTL policy in UiReadCacheOptions). Registered by AddUIShared but opt-in per UI service through an optional constructor parameter, and no consumer app passes it today; both server tiers are unchanged. Revised 2026-09-03: AddPublicEndpointPolicy has a bypass-roles overload (ADC uses it for ten of eleven policies; a caller in a bypass role skips the cache entirely), both adopters register the output-cache store through the framework's AddRedisOutputCaching and Tier 1 through AddRedisCaching (the raw AddRedisClient/AddRedisDistributedCache calls live once in MMCA.Common.Aspire), and every ADC and Store service opts into HybridCacheService inside its Redis conditional, so the two-way auto-swap is the no-Redis path only.
027 Multi-locale i18n (supersedes 011) en-US + Spanish via co-located .resx / IStringLocalizer<T>; backend errors localized server-side at the edge keyed by the existing Error.Code (English Message as fallback; Code/Type/Source/Target cross the wire verbatim, and since 2026-08-27 the client matches the structured errors array rather than the ProblemDetails title, so the three fixed English title strings it used to branch on are no longer a constraint); one culture cookie is the source of truth across SSR/Server/WASM, forwarded to services as Accept-Language, and persisted to User.PreferredCulture. Culture-less formatting is a build gate (MA0076 at error severity since 2026-06-29) and translation completeness is a fitness gate (ResourceTranslationsAreComplete), with a qps-Ploc pseudo-locale pass required on all three ui-e2e browser engines. Applying a culture is host-specific behind ICultureApplier; the MAUI hybrid applier switches in process by setting the thread defaults only (never the calling thread's culture, which would pin the launch language) and re-booting the WebView.
028 Day/Dark theme mode Connects the already-defined MMCATheme.PaletteDark via MudThemeProvider @bind-IsDarkMode, owned by the shared MmcaThemeProviders component that the shared MainLayout renders; a ThemeService persists the choice to cookie + localStorage + User.PreferredTheme, defaulting to the OS prefers-color-scheme; reuses ADR-027's cookie/profile persistence and ships the toggle in the shared MainLayout beside the culture switcher. The no-flash SSR bootstrap is not yet wired for theme (a first-paint flash is possible). Revised 2026-08-31: MmcaThemeProviders owns all four Mud providers together with the Day/Dark lifecycle and takes a MudTheme parameter defaulting to MMCATheme.Instance, so a host can supply its own theme without replacing the component.
029 Auth brute-force protection Always-available ILoginProtectionService layered on top of ADR-019's auth-ip per-IP cap: email-keyed exponential-backoff login lockout (holds across source addresses) + per-IP registration cap, cache-backed (ADR-026), returning Result (uniform 401, not a 429). The check/increment/reset sequence is centralized in AuthenticationServiceBase<TUser>; both apps' Identity flows inherit it by subclassing.
030 Startup sole-migrator Each service self-applies its EF migrations at boot (DatabaseInitStrategy=Migrate, minReplicas:1) and is the sole migrator (no deploy-step sqlcmd backstop) deliberately overriding the framework's None-for-prod default after a startup-race incident. Revised 2026-08-07: the same startup owner also runs every module seeder unconditionally (all environments, even under None), with idempotency delegated to each seeder.
031 Feature-flag management Microsoft.FeatureManagement (config section + Percentage/TimeWindow/Targeting filters) enforced on two surfaces for one flag name: [FeatureGate] on controllers (a ProblemDetails 404 via DisabledFeatureHandler) and IFeatureGated on CQRS handlers (the outermost decorator, NotFound). Disabled = 404, not 403. Revised 2026-08-18: CurrentUserTargetingContextAccessor is registered via WithTargeting, so the built-in Targeting and Percentage filters give consistent per-user bucketing across replicas instead of per-process assignment (the JWT sub claim falling back to Identity.Name, role claims as Groups, empty context for anonymous callers). Registration-only: no decorator changed. Revised 2026-08-31: the targeting id is the JWT sub claim, so per-user rollout bucketing keys on the same identifier the rest of the stack does.
032 Password hashing (PBKDF2 + legacy migration, superseded) One framework IPasswordHasher: new passwords use PBKDF2-HMAC-SHA512 (32-byte salt, 600k iterations, FixedTimeEquals compare); VerifyPassword picks the algorithm by salt length so pre-existing HMAC-SHA512 records (128-byte salt) still verify and migrate to the new format on the owner's next password set. The legacy branch is load-bearing: dropping it silently breaks every old login. Superseded by ADR-102.
033 Resource-ownership authorization A row/resource-level ownership axis beside ADR-020's RBAC (the question ADR-020 explicitly scopes out): an OwnerOrAdminFilter action filter 403s a request whose owner parameter (route value or model-bound argument) mismatches the caller's owner claim, with the vocabulary host-configurable via OwnerOrAdminFilterOptions (claim, parameter name, bypass role; defaults customer_id/id/Admin); an OwnershipHelper builds an ownership Specification that row-scopes collection queries and CSV exports alike, through the one read hook (ADR-078) all the read actions share. Opt-in per controller/handler, claim-trusting (ADR-004), not full ABAC. Adopted by MMCA.Store and (with sub/Organizer vocabulary) MMCA.ADC's Engagement module. Revised 2026-08-31: the filter 403s a single-resource mismatch, and a fail-closed RequireResolvableOwner() gate rejects the non-admin caller whose owner claim cannot be resolved rather than serving an unscoped read; the opt-outs are deny-by-default and each one names the capability guard that stands in its place.
034 Generic entity controllers + dynamic query contract Every entity inherits a generic REST surface (EntityControllerBase / AggregateRootEntityControllerBase: list/paged/lookup/by-id + create/delete) plus an OData-lite query contract: sparse fieldsets (fields), per-type IFilterStrategy filtering via QueryFilterModelBinder, sort, pagination + X-Pagination, a MaxUnboundedResultLimit ceiling, and the two-path include strategy. Write-once over bespoke endpoints; the wire contract tracks the entity model (DTO-mediated). Composes with ADR-001 / ADR-002 / ADR-013 / ADR-017. Extended by ADR-099 (2026-08-29): the generic write side gains update (an IEntityUpdateApplier the module implements, UpdateEntityCommand/UpdateEntityHandler, and AddEntityCrud for all three verbs), with the PUT on a new derived CrudEntityControllerBase so this record's base keeps its generic arity. Revised 2026-08-31: the generic update also has a command-aware applier, IEntityUpdateCommandApplier, and it stays on that separate controller base rather than on either base this record ships.
035 Optimistic concurrency via RowVersion round-trip Every auditable entity carries a RowVersion concurrency token (SQL Server rowversion, IsConcurrencyToken elsewhere) that round-trips through the client over HTTP's own conditional-request headers. A read DTO implements IConcurrencyAware (non-nullable RowVersion) and GetByIdAsync renders it as a weak W/"base64" ETag (silent no-op when the DTO has none, fields= projections handled); the client echoes that tag in If-Match on its next write, where [SupportsIfMatch] (the attribute IS the filter, no DI) decodes it into HttpContext.Items and the handler stamps it via IWriteRepository.SetOriginalRowVersion so EF compares it inside the UPDATE's WHERE clause. The header is the one transport and it is required: no header (or *) is 428 Precondition Required and the action never runs, a malformed tag is 400, and a stale one is 412 Precondition Failed, all three RFC 9457 problem details built through the registered ProblemDetailsFactory with the errors extension carrying Concurrency.PreconditionRequired / MalformedIfMatch / PreconditionFailed. Update requests carry no token at all, build-gated by the inverted UpdateRequestsAreNotConcurrencyAware fitness rule (subclassed in ADC and Store): a token in the body would give the same check a second competing source. The 412 rewrite keys on the conflict outcome rather than its cause, so an unrelated constraint violation on a guarded request also reports 412 with its own problem details intact. The column ships in each database's InitialCreate, so adoption is a schema step per database. Distinct from request idempotency (ADR-017) and inbox dedup (ADR-021). No conditional-GET/304 path is decided.
036 External OAuth login (Google/GitHub) AddExternalAuthProviders federates third-party sign-in behind a short-lived ExternalLogin cookie; OAuthControllerBase completes the handshake and swaps a single-use, 2-minute cached code for the app's local JWT pair (tokens never ride the redirect URL). The local User links by provider+key, by email (validated through Email.Create first, rejecting an unparseable provider email with ExternalEmailInvalid, then guarded since 2026-07-19: ADC rejects the link with ExternalEmailNotVerified when the provider did not assert the email verified), or is created externally (CreateExternal, LoginProvider/ProviderKey fields). Config-gated per provider (OAuth:<Provider>:ClientId), inert until configured; adopted by MMCA.ADC only (MMCA.Store does not wire it).
037 Field-level encryption at rest (AES-256-GCM EF converter) An EncryptedStringConverter transparently encrypts string columns with authenticated AES-256-GCM (random 12-byte nonce, 128-bit tag, Base64 version+nonce+ct+tag envelope; consumer supplies the 32-byte keys). A versioned key ring makes rotation zero-downtime: writes stamp the current version, reads resolve their key from the version byte in the stored value, and AES-GCM authenticates that byte as associated data so it cannot be rewritten. Shipped and unit-tested but unadopted: no entity configuration wires it yet (the shipped-but-latent posture ADR-018 also records), which is exactly what made the un-versioned format free to replace.
038 Supply-chain provenance (SBOM gate + lock files + vuln audit) Four build-gating controls for a published framework: a CycloneDX SBOM as a hard release gate, committed NuGet lock files, a CI --vulnerable --include-transitive audit that fails on any row except NuGetAuditSuppress-accepted advisories (single source of truth, re-applied in CI; zero suppressions active since the 2026-07-20/21 SQLite direct-pin fix), and packageSourceMapping pinning every package to nuget.org. Extends ADR-016 from versioning/licensing into provenance.
039 Live channel push (ephemeral events over the notification hub) NotificationHub gains JoinChannel/LeaveChannel group membership (keys validated against PushNotificationSettings.ChannelKeyPattern) and a ReceiveChannelEvent client method; a new ILiveChannelPublisher Application abstraction (Null default, SignalR group-send impl swapped by AddPushNotifications, ADR-024 pattern) publishes ephemeral (channelKey, eventName, payloadJson) events. One WebSocket carries durable notifications and lossy live events; the durable-vs-ephemeral split lives at the publisher boundary. Client side: multicast OnChannelEvent subscriptions + automatic channel re-join on reconnect.
040 Authenticated output caching for public reads PublicEndpointOutputCachePolicy (+ AddPublicEndpointPolicy extension) replaces the built-in default policy on [AllowAnonymous], user-independent GET endpoints so an Authorization header no longer bypasses the output cache. The UI attaches a Bearer token to every request, so the default policy served 0% cache hits to logged-in users and every read landed on the database; this policy keeps the GET/HEAD-only, no-Set-Cookie, 200-only guards and takes expiration + eviction tags per named policy. Strict contract: never apply to identity-dependent payloads. Amended 2026-08-31: a shared privileged read audience bypasses the cache on ten of ADC's eleven public policies, so a caller entitled to see more than the public payload is served from the source; and the store behind the policy is Redis-backed wherever the service runs more than one replica.
041 Observability and telemetry strategy Shared Aspire OpenTelemetry baseline (AddServiceDefaults) plus framework-specific instrumentation where auto-instrumentation is blind: RED duration histograms from the CQRS logging decorators (meter MMCA.Common.Cqrs, tagged by outcome) and an outbox dead-letter counter (MMCA.Common.Outbox), with X-Correlation-ID middleware (W3C trace-id fallback, echoed on the response) tying logs to traces. Fail-safe cost knobs: ParentBased head sampling via Telemetry:TracesSampleRatio (sample-all unless a valid ratio is set), OutboxPollFilterProcessor dropping idle poll spans from export, and Telemetry:DisableHttpClientMetrics / Telemetry:DisableRuntimeMetrics gating those two meter families (on by default). Dual exporters: OTLP and Azure Monitor, either or both. Amended 2026-08-18: two new failure-only counters, cache.eviction.failed (MMCA.Common.OutputCache) and besteffort.dispatch.failed (MMCA.Common.BestEffort); the meter inventory is corrected in place, since the authoritative list is the Aspire subscription block (Extensions.cs:164-170) and it now carries seven meters, two of which (Idempotency, Scheduler) this record never named; and the correlation id now starts one hop earlier, at the Gateway (ADR-088), so "one id per request" finally holds across the gateway hop. Neither new counter is wired to an alert. Amended 2026-08-31: the application logging pipeline is Serilog registered as ONE additional ILogger provider (builder.Logging.AddSerilog) and never UseSerilog(), which would replace the whole ILoggerFactory and silently bypass the OpenTelemetry to Azure Monitor provider AddServiceDefaults wires; Debug in Development and Information elsewhere, a rolling file sink everywhere except Production, and a pre-DI bootstrap logger factory for module discovery; adopted by all seven ADC/Store service hosts and by neither Gateway nor either UI host (MMCA.Store.UI.Web hand-rolls the same shape inline), guarded by one MMCA.Common test.
042 Device capability abstraction (MAUI Blazor Hybrid) Per-capability contracts in MMCA.Common.UI (Services/Capabilities, 18 at introduction, 23 today after ADR-044/045, geocoding and local-cache additions) with TryAdd null/browser fallbacks registered by AddUIShared; heads override AFTER it with plain Add (AddBrowserDeviceCapabilities / the new MMCA.Common.UI.Maui package's UseMauiDeviceCapabilities). UI.Maui is the one MAUI-TFM exception: outside MMCA.Common.slnx, built/packed by windows CI jobs, layer rule UI + Shared only. Deep links funnel through the singleton IDeepLinkDispatcher + DeepLinkListener; ExternalLink replaces raw target="_blank" (dead inside BlazorWebView). Amended 2026-09-03: AddMauiDeviceCapabilities covers 20 of the 23 contracts; IPushDeviceTokenProvider comes from the platform-conditional AddMauiPushDeviceTokenProvider, IBarcodeScannerService stays behind UseCommonBarcodeScanner, and IDeepLinkDispatcher needs no native override.
043 Mobile deep links, app association, native OAuth callback OAuth:AllowedReturnUrlSchemes lets OAuthControllerBase.CompleteAsync redirect the single-use completion code (never tokens) to an allow-listed custom scheme (atldevcon://oauth-complete) so MAUI's WebAuthenticator can capture it; http(s) never matches (no open redirect), empty list = prior behavior, OriginalString echoed (native callback matching is exact). App association (assetlinks.json with the Play App Signing fingerprint + apple-app-site-association) is served by each app's UI.Web host via MapAppAssociationEndpoints, not the gateway; incoming URIs reuse the ADR-042 dispatcher because all heads share one route table. Framework leg fully shipped (allowlist + association endpoints + MauiExternalAuthBroker); adopted by ADC, not yet by Store. Revised 2026-08-31: the shared completion page performs the single-use-code exchange itself and branches on a Result failure instead of throwing (ADR-013), and ADC ships the whole wave: association endpoints, the atldevcon allowlist entry, iOS and Android callback registrations with AutoVerify App Links, and a real served signing fingerprint.
044 Native push delivery (third notification channel) Amends ADR-024: INativePushSender + IPushDeviceRegistrar (Null defaults, Azure Notification Hubs impls swapped by AddNativePushNotifications only when the NativePush section is enabled and complete) give the send pipeline an OS-level FCM v1/APNs leg that reaches backgrounded and killed apps. Installations carry user:{id} tags (sends target users, OR-chunked at the hub 20-tag cap); SendPushNotificationHandler third leg is non-fatal (inbox stays the source of truth); DevicesController (PUT/DELETE, any authenticated user) manages installations; client side is IPushRegistrationService/IPushDeviceTokenProvider (ADR-042 pattern) - the Null token provider keeps credential-less builds wired but inert.
045 Managed file storage + user avatars (BR-116a) IFileStorageService (Null default; Azure Blob impl via AddAzureBlobFileStorage when the FileStorage section is complete - ServiceUri = DefaultAzureCredential, ConnectionString = Azurite) + IImageProcessor/ImageSharpImageProcessor (decode, auto-orient, exact-square crop, strip ALL metadata, re-encode JPEG - only pixels survive untrusted uploads) + IMediaPickerService UI capability (native pick/capture; web heads render InputFile instead). Avatar contract: 2 MB in, 256x256 JPEG out, avatars/{userId}-{random8}.jpg in a public-read container, [Pii] URL nulled + blob deleted on anonymize.
046 HTTP API versioning strategy One AddCommonApiVersioning call wires header-based versioning (api-version reader, default 1.0 assumed when unspecified, supported/deprecated versions reported on every response); ServiceInfoControllerBase ships a live v1.0-deprecated + v2.0 exemplar and the shared ServiceInfoVersioningContractTestsBase fitness contract asserts the headers per repo. Adopted by every extracted ADC/Store service host plus Helpdesk. The HTTP-contract axis, distinct from ADR-010's integration-event schema versioning. Amended 2026-08-12 (v1.146.0): the same call registers ApiParameterDescriptorBackfillProvider, so an unbound route token ({version}, {tenant}) can no longer 500 the OpenAPI document.
047 Soft-deleted-user session revocation SoftDeletedUserMiddleware (BR-133, after authentication, before authorization) returns 401 for authenticated callers whose User.IsDeleted, via ISoftDeletedUserValidator behind a 30-second cached check (SoftDeletedUserCache.MarkerDuration); the validator is lazily resolved so hosts that do not register one (non-Identity services, Helpdesk) no-op, and cache or validator failure deliberately fails open. Since 2026-08-07 the record reflects the shared generic SoftDeletedUserValidator<TUser> in MMCA.Common (no per-app query classes) and the adoption asymmetry: ADC writes an immediate deletion marker on delete, Store relies on the passive 30-second TTL. Bounds the stateless-JWT revocation window (ADR-004) to roughly the cache duration instead of the token lifetime.
048 Primitive identifier type aliases Entity IDs are primitives behind per-module global using {Entity}IdentifierType = ... aliases, linked solution-wide via Directory.Build.props, chosen over DDD strongly-typed ID structs: readable signatures with zero EF/serializer/OpenAPI friction, at the cost of no compile-time protection against swapping two same-typed IDs. Wrapper structs were considered and deferred: no wrapper-struct identifier type exists in any repo. Revisited by ADR-085 (2026-08-18): the deferral is re-evaluated, priced (44 aliases, 43 of them int; 3,192 occurrences across 1,001 files to migrate) and upheld, now with three named revisit triggers instead of an open-ended "not now".
049 Library-scoped ConfigureAwait(false) policy Packaged non-UI framework code awaits with ConfigureAwait(false), enforced as a build gate (CA2007 = warning for Source/** in MMCA.Common's .editorconfig repo-delta, UI component packages excluded); the application repos keep ConfigureAwait analyzers off. Protects the MAUI head (ADR-042) and any non-ASP.NET consumer from library context-capture deadlocks. Revised 2026-08-31: the gate is CA2007 raised to warning for [Source/**.cs] (a build error under TreatWarningsAsErrors) and scoped back to none for the three MMCA.Common.UI* packages, with the application repos and every test tree keeping the baseline off; re-measured at 910 gated await sites of 1,012.
050 JWT + single rotating refresh token (superseded) One issuance workflow in AuthenticationServiceBase<TUser>: a short-lived stateless JWT access token (default 15 min) plus one server-stored, opaque refresh token per user that rotates on every use; a presented token that mismatches the stored one (or is expired) triggers RevokeRefreshToken and 401. Each rotation re-stamps the expiry (Jwt:RefreshTokenExpirationDays, default 7 days), so the lifetime is a sliding inactivity window, not an absolute session cap; refresh is bound to the same principal via GetPrincipalFromExpiredToken. Single-token-per-user means a new login signs out other devices' refresh chains. Superseded by ADR-097. The rotation, reuse-detection and sliding-expiry policy survives there over per-device rows hashed at rest; the single column, UpdateRefreshToken/RevokeRefreshToken and the user_id claim are gone from the code.
051 Client-side auth token lifecycle across render modes One ITokenRefresher abstraction with two head-specific strategies: browser heads (Server/WASM) refresh through the same-origin proxy (SameOriginProxyTokenRefresher, HttpOnly cookie via /auth/session/token, ADR-022) while MAUI refreshes directly against the API (DirectApiTokenRefresher) persisting the rotated pair in OS SecureStorage; AuthDelegatingHandler stamps the bearer on the APIClient pipeline, render-mode-aware ITokenStorageService implementations (WASM in-memory / Server SSR-vs-interactive, single-flight with 30s skew) hold the tokens, and JwtAuthenticationStateProvider drives Blazor auth state. Client half of ADR-022/ADR-050. Revised 2026-08-07: the MAUI SecureStorage implementation is the shared MauiTokenStorageService in MMCA.Common.UI.Maui (AddCommonMauiTokenStorage()), consumed by both apps instead of per-app copies. Revised 2026-08-31: the contract is three abstractions, ITokenStorageService, ISecureTokenStore and ITokenRefresher; both browser heads hold the access token in memory only while MAUI persists the rotated pair behind a guarded ISecureTokenStore, and the 30-second expiry skew plus single-flight refresh hold on all three heads.
052 Background job execution (bounded queue + hosted drain) In-process work that outlives a request runs as a bounded Channel<T> singleton plus a SingleReader BackgroundService drain, never an untracked Task started from a controller: the host can then cancel and await it on shutdown instead of a deploy killing it mid-run. Full mode encodes what the work is worth (DropOldest for ephemeral broadcasts, whose drops are only visible through the itemDropped callback since TryWrite always succeeds; Wait plus non-blocking TryWrite for expensive runs, which refuse rather than discard). Expensive work dedups by natural key across queue AND execution, refusing duplicates with 409. Post-commit enqueue takes two shapes, not one: two call sites attach to a domain event so ADR-003 deferral supplies the commit boundary, and four enqueue from the command handler after the save, which is genuinely post-commit only because no Engagement command implements ITransactional. Revised 2026-08-31: three of those four live-channel sites inherit the save-then-enqueue order from MutateEntityHandlerBase's post-save hook rather than restating it, and only SubmitQuestionHandler sequences it by hand, so the ordering is a rule a future edit must keep at exactly one site instead of four. Instances: LiveChannelPublishQueue (ADR-039), SessionScoringQueue.
053 Dual-registry package publishing (trusted publishing) Every release pushes the same nupkgs to nuget.org and GitHub Packages from one tag, because the GitHub Packages NuGet registry requires a read:packages PAT even for public packages, so the documented dotnet add package MMCA.Common.API failed for everyone outside the account. nuget.org becomes the documented install path and the only public download signal; GitHub Packages is retained as a mirror. Auth is keyless: each publishing job exchanges its GitHub OIDC token via NuGet/login@v1 for a one-hour key, authorized by a nuget.org policy pinned to the permanent GitHub ids of owner, repository, and workflow file (nuget.org now marks API keys "Not recommended"). No stored secret, therefore no rotation and no expiry-failure mode; the workflow file name becomes load-bearing in exchange. A github.repository_owner guard keeps a fork from releasing to either registry. Listing metadata (PackageProjectUrl, PackageIcon, PackageTags, per-package Description, packed README) is part of the deliverable. No backfill: nuget.org starts at the first release after this decision. Revised 2026-08-31: both publishing jobs, the ubuntu one and the MAUI windows one (ADR-042), push to both registries from the same tag, and the published package-id set is owned by MMCA.Common/FACTS.md rather than restated here.
054 Saga compensation + reconciliation backstop Cross-boundary consistency without two-phase commit, the question ADR-003 / ADR-006 / ADR-021 each leave open. Each workflow step raises a domain event and the compensating action lives in its own handler (OrderCancelledSagaHandler, OrderPaymentFailedSagaHandler) running in its own DI scope, so it commits after (not inside) the originating transaction. Idempotency is a persisted aggregate marker (Order.InventoryRestored) committed by the SAME SaveChanges as the compensating writes, which is stronger than ADR-021's record-after-success inbox; concurrent redeliveries are serialized by the ADR-035 RowVersion token. A periodic PaymentReconciliationService sweep is the saga-timeout backstop, asking Stripe for authoritative session status and driving the same guarded transitions a lost webhook would have, losing races to the webhook by design. Adopted in MMCA.Store's Sales module only. See ADR-086 (2026-08-18) for the orchestrated alternative: deferred with a recorded shape and trigger, and with this sweep remaining underneath any future coordinator.
055 Repository + Specification data-access contract The read contract is ISP-split into IEntityReader (id lookups) and IEntityQuerier (collections, projections, counts), composed by IReadRepository, which alone exposes the raw IQueryable surfaces. ApplicationLayer_DoesNotUseRawQueryableSurfaces fails the build on .Table / .TableNoTracking* in Application code (opt-in per repo: Common, ADC and Store today, with a documented AllowedFiles ratchet; Store adopted 2026-07-28 with an empty allowlist), because a raw-queryable handler is EF-coupled and cannot move behind a gRPC boundary later. Predicates are composable Specification expression trees (And / Or / Not / Inline) fed into the same query pipeline. The split is shipped and now consumed in both applications (twelve read-only declarations narrowed across eight files in ADC, three in Store, with a holder that genuinely needs both halves deliberately keeping the composite), acquired by implicit conversion from GetReadRepository<,>() rather than by DI: IUnitOfWork still hands out only the composites, and no registration was added for the narrow interfaces because a container-resolved one would bypass the unit of work's data-source resolution and per-scope cache. Referenced but never decided by ADR-018 / ADR-033 / ADR-035 / ADR-048. Revised 2026-08-18 (substantive, five changes): QuerySpecification gives a specification ordering, include paths, paging, tracking and a scoped soft-delete-filter escape, superseding the predicate-only trade-off; composition drops Expression.Invoke for a parameter-rebinding ExpressionVisitor (composed once per instance) and gains fluent And/Or/Not extension members, retiring the provider-bet trade-off; IEntityQuerier gains specification-first ListAsync (plus a projecting overload), CountAsync and AnyAsync, and IEntityQueryService widens from the abstract Specification to ISpecification; an optional IEntityDTOProjector pushes DTO projection into SQL via ExecuteProjectedAsync when a projector is registered, the read is untracked and no include crosses a data source, falling back silently otherwise; and keyset pagination arrives as GetPageByCursorAsync (KeysetPageRequest/KeysetCollectionResult, a versioned base64url cursor, Result-based validation failures) alongside a correctness fix that makes paginated reads deterministic (Id-ordered by default, Id tie-break appended to a caller sort; unpaginated reads stay deliberately unordered). Revised 2026-08-31: IEntityQuerier also carries first-row, grouped-aggregate and include-deleted reads, and the narrowed read dependents now ship in ADC, Store and Helpdesk alike.
056 Blazor render-mode strategy InteractiveAuto is declared once on the root router of each web head (no per-page @rendermode anywhere) and prerendering stays on, so an interactive page renders SSR, then Server, then WASM. The resulting double fetch is removed once, in the shared DataGridListPageBase, by persisting the prerendered payload via PersistentComponentState (the persist callback declares InteractiveAuto explicitly, and the prerender fetch is time-bounded at 5000 ms); detail pages skip the prerender fetch instead, and one page family hand-rolls its own copy. Both runtimes register the same service set, which the WASM-compatible layer rule in MMCA.Common.LayerEnforcement.targets is what makes possible. InteractiveServer is pinned only under E2E config flags. Not uniform: MMCA.Helpdesk and the Common UI gallery are Server-only with no .Client project, and nothing enforces the root mode. Taken as given context by ADR-022 / ADR-027 / ADR-028 / ADR-051, none of which decided it.
057 Expand/contract schema evolution as a CI gate A migration added by a PR may not call DropColumn / DropTable / DropIndex inside Up() without an EXPAND-CONTRACT-OVERRIDE marker, because deploy rollback is revision-only and never reverts schema: the previous release has to keep running against the new one. "Added by this PR" is git diff --diff-filter=A against origin/<base>...HEAD, path-scoped, Designer files skipped and the pre-split frozen archives out of scope; only the Up() range is scanned. Documented as // EXPAND-CONTRACT-OVERRIDE: <why this drop is safe one release back> but enforced as a bare substring match, so one occurrence exempts every destructive operation in that migration. A PR-only merge gate, not a deploy gate: MMCA.ADC since 2026-07-19, ported to MMCA.Store 2026-07-25. MMCA.Helpdesk has neither the gate nor a deploy workflow and carries an unmarked DropIndex; MMCA.Common has no migrations at all. ADR-030 decides who applies migrations, never what shape one may take.
058 Runtime conformance suites shipped as a package MMCA.Common.Testing exports six abstract contract bases (ProblemDetails, OpenAPI, service-info versioning, security headers, graceful shutdown, decorator order) that a consuming host subclasses to prove it wired the framework's runtime contracts correctly. They run against a really booted host (WebApplicationFactory plus a GUID-named throwaway SQL database, Respawn between tests), not by reflection over registrations, which is the boundary against ADR-015: that record scopes itself to structure and registration and says so. Subclasses stay thin (usually two probe requests or a resource list). Adoption is partial and named as such: OpenAPI on every service host, ProblemDetails on all seven REST hosts (ADC Notification closed the gap 2026-08-13), versioning on one host per repo, and security headers plus graceful shutdown on the Gateways only, so no service host asserts either. MMCA.Helpdesk adopts exactly one, the decorator suite (2026-08-18); the other five HTTP-facing bases have no Helpdesk subclass. Revised 2026-09-03: seven contract bases, the seventh being MmcaGatewayHardeningTestsBase (edge rate limiter, bypass list, correlation echo, downstream readiness), adopted by both gateways; three bases are fixture-driven and three boot the host through a factory, so only the three fixture-driven suites need SQL.
059 The IModule contract and module composition The composition model ADR-008's "a service is the monolith with one module enabled" rests on. IModule is five members, three of them defaulted, so a leaf module is Name plus Register. Discovery is a reflection scan over the assemblies the host names explicitly: moduleAssemblies is a required parameter, so a host declares its module set in the composition root rather than depending on what happens to be loaded; an assembly that throws from GetTypes() is logged and skipped, not fatal. Registration order is a Kahn topological sort over the declared names, so a dependency's registrations are in the container before a dependent's Register runs, and a cycle throws at startup naming its members. Enablement is configuration and absence means off: a module missing from the Modules section is disabled even though ModuleSettings.Enabled itself defaults to true. A disabled module is represented by stub registrations rather than by absence, which is what keeps cross-module interfaces resolvable in an extracted host.
060 Performance-regression gate BenchmarkDotNet Short run plus a committed baseline, verified by a dependency-free checker and wired as a required merge check on MMCA.Common. Allocations are gated absolutely (eight per-benchmark byte ceilings, compared strictly, with roughly 25% headroom baked into the committed numbers); latency is gated only as a ratio between two benchmarks, never as an absolute time, which is what keeps the gate valid on shared, noisy CI runners. A missing measurement fails rather than passes: a rule naming a benchmark absent from the results, or an empty results directory, is a violation, so the gate cannot go quietly vacuous. Moving a number deliberately means updating the baseline in the same PR. MMCA.Common only: MMCA.ADC and MMCA.Store have no benchmark suite (their perf artifact is an on-demand k6 load test, not a PR gate), and MMCA.Helpdesk has neither.
061 Runtime secret management (Key Vault refs + managed identity) Every production secret lives in Key Vault and reaches a Container App as a keyVaultUrl secret reference resolved by one shared user-assigned managed identity (Key Vault Secrets User), never as a plaintext app secret or env var; the same identity is also the ACR pull credential, and the vault plus both role grants are bootstrapped out of band because the deploy principal has Contributor without role-assignment-write. Adopted identically by MMCA.ADC and MMCA.Store (Gateway/UI carry secrets: []); MMCA.Common ships the shape as a compile-only reference sample and MMCA.Helpdesk has no infra at all. SQL auth is staged, not switched: useManagedIdentitySql (default false) swaps the connection string to Authentication=Active Directory Managed Identity, with an additive Entra admin that never sets azureADOnlyAuthentication, so every service still authenticates with the shared SQL login and password until an operator runs the per-database grants and flips the flag. Revised 2026-09-03: fifteen ADC and eleven Store vault secrets; the ADR-088 synthetic-traffic-secret is consumed by both Gateways through secretRef, so only the UI apps still declare secrets: [].
062 SLO alerting as code with an alert-to-runbook build gate Each consumer's infra/main.bicep declares sloAlertSpecs (key / KQL query / threshold / severity) materialized as Log Analytics scheduledQueryRules, because a metric alert cannot express the 401/499 and SignalR-connection-lifetime predicates that made the old rules page on routine traffic. The superseded metric alerts are no longer declared in either template (revised 2026-08-31); the replacements carry distinct -v2 names, which is what made dropping them safe given that an incremental ARM deploy never removes a resource that left the template; all active rules route to one unconditional action group (the email parameter is required), and a saved workbook renders the same signals. ObservabilityConventionTestsBase in MMCA.Common.Testing.Architecture fails the build when an alert has no severity-matching ### ...-alert-<key> (sev N) section in infra/OPERATIONS.md, when a runbook section is orphaned, or when fewer than MinimumAlertSpecs (default 3) are discovered. MMCA.ADC and MMCA.Store adopt it via an embedded bicep/runbook pair plus a body-less subclass; ADC's outbox, SQL and gateway-availability alerts and Store's outbox-dead-letter and gateway-availability pair (merged 2026-08-13) all sit outside the parsed window, so five extras are ungated. MMCA.Helpdesk has no infra/ template at all. Revised 2026-09-03: SLO rules evaluate every fifteen minutes in both templates; ADC carries three operational scheduled-query rules plus the availability alert and Store three standalone ungated alerts including revision-activation-failed (ADC's has no runbook section yet).
063 WCAG 2.1 AA as a shipped test contract and CI gate AxeOptions.Wcag21Aa in MMCA.Common.Testing.E2E pins every axe scan to the four WCAG 2.0/2.1 A+AA tags with axe "best-practice" rules deliberately out of scope, which is what makes the check blockable rather than advisory; a violation throws AccessibilityViolationException with the offending markup, and the package's Identity workflow bases assert it so a subclassing app inherits the scan. Exactly one exception is recorded as a second option value, Wcag21AaExceptMudPagerCombobox, disabling only aria-input-field-name for pages whose sole combobox is MudBlazor's unlabelled MudTablePager select. MMCA.Common runs it across chromium/firefox/webkit as three required merge checks; ADC and Store run a chromium-only leg as a deploy gate that is ui-scoped and may legitimately skip. The gate already owns theme tokens (light WarningContrastText, dark Primary/Error contrast text, brand Teal 700). Revised 2026-08-31: the Common gallery now carries twelve scans across eight classes, one of them a signed-in devices/sessions page that seeds the gallery's fake auth cookie so an [Authorize] surface is covered, while MMCA.Helpdesk still adopts none of it (it pins the package, references it nowhere and has no E2E project).
064 Deploy preconditions as proof-of-recency gates Production deploys block on the AGE of out-of-band verification, not only on green tests: dr-freshness / load-freshness / cross-service-freshness sit in deploy.needs and query the Actions API for the newest successful DR drill (8 days), k6 load run (35 days) and Testcontainers broker round-trip (5 days), failing the deploy when the newest proof is older than its window and when there is no successful run at all. The broker gate keys off the cross-service JOB's conclusion rather than the run's, because a skip-if-unchanged guard makes a run green with nothing executed and an advisory smoke job makes a proven run red. Break-glass is a workflow_dispatch pair (skip_freshness_gates + skip_justification) that errors out on an empty reason and otherwise exits 0 with the reason in the step summary and a run annotation; it is unreachable on a push and covers all three gates at once. MMCA.ADC and MMCA.Store only, in near-identical form; MMCA.Helpdesk and MMCA.Common have no deploy pipeline at all. ADR-009 requires the drill be recorded; it never gave a record an expiry.
065 Scaffolding templates derived from the reference app A dotnet new pack, MMCA.Templates (mmca-app, mmca-module, mmca-command, mmca-query), replaces the transcription work of the build-by-hand guide (Getting Started is now the six-step dotnet new path): standing up a new app by hand meant 12 projects and thousands of lines before any business logic, several of them silently load-bearing (AddApplicationDecorators() last, WaitFor(sql) not the database resource, a module absent from IArchitectureMap). The template content is the MMCA.Helpdesk reference app ITSELF, staged at pack time from its own tree with .template.config/ at the repo root, so no second copy of the solution exists to drift and the app whose CI keeps it green is what adopters receive. One thing is deliberately withheld because a rename or shape flag invalidates it and no fixed value suits every generated name (using-directive and alias order plus one expression-body preference: SA1210/SA1211/IDE0021 relaxed in the STAGED .editorconfig only, leaving the shared analyzer baseline untouched), while the IntegrationEventContractTests wire-contract freeze now SHIPS, frozen on the adopter's own event names and green on arrival, guarded at stage time by a match-count assertion rather than deleted (revised 2026-08-31). Generated apps ship build/add-module.ps1, which runs dotnet new mmca-module and performs the seven wire-ups the template can only print, plus the first migration. The gate is a template-smoke job, not the seed's build: the seed builds local-source against MMCA.Common@main while a generated app builds package-mode against a release, and it sweeps for residual Helpdesk/Ticket tokens, since sourceName and symbol replacement run as separate passes and a nested token half-applies silently. Named outside the MMCA.Common.* family on purpose (ADR-016 lockstep, different repo, different cadence), which also keeps the pack out of FACTS.md's package count.
066 Broker transport selection + dev/prod parity One IMessageBus abstraction with three MessageBusProvider values: InProcess for tests and the monolith, RabbitMQ wired by the Aspire AppHost locally (WithBroker), Azure Service Bus injected by both apps' infra/main.bicep in production (Standard tier; MassTransit's topology needs Manage rights). Identical exponential retry is configured per transport, and a dedicated Service Bus emulator test tier exists to prove the production transport binding (non-gating, cron). Helpdesk stays InProcess. Fills the transport gap ADR-003 and ADR-016 both leave open.
067 Shared Blazor shell + IUIModule composition The framework package ships the router, layout, nav menu and routable shell pages (login, register, home, not-found, forbidden, notification inbox); each module plugs in by implementing IUIModule (NavItems, Assembly for AdditionalAssemblies route discovery, app-bar and layout component extension points), and Routes.razor enumerates the registered modules at runtime. The UI-layer counterpart of ADR-059's IModule; adopted by every ADC/Store module, Common's Notification module and the UI gallery. MMCA.Helpdesk keeps its own shell and does not adopt it. Revised 2026-08-31: MudBlazor is the single component vendor and it sits behind IToastService / IAppDialogService, registered by one shared AddCommonUiFacades() that both the shell and the ADR-103 bUnit test base call, so a component test exercises the same facade wiring the app boots with.
068 Value objects as validated domain primitives Seven sealed record value objects over an abstract ValueObject base (Email, Money, Currency, PhoneNumber, Address, DateRange, DateTimeRange), each a private constructor plus a Result-returning Create factory (fitness-enforced); shared constraints live in *Invariants classes where reused. EF mapping is OwnsMoney (owned type) or value converters (no schema change); Currency.None is the null-object sentinel on materialization; JSON/XML round-trip via [JsonConstructor], converters and DataContract, gRPC via a hand-mapped MoneyV1. The deliberate opposite of ADR-048's primitive identifier aliases: identifiers cross boundaries, domain values carry invariants. Revised 2026-08-31: all seven ship in MMCA.Common.Shared, Money, Email and Address are the three adopted by Store and ADC, and Money alone also exposes non-validating construction sugar beside its Result factory.
069 Shared DataProtection key ring for scaled-out hosts AddCommonDataProtection persists the DataProtection key ring to one Azure blob under DefaultAzureCredential so cookies and antiforgery tokens minted by one replica decrypt on another; key-ring encryption at rest (Key Vault) is a deliberately independent second gate, so a lagging role assignment cannot take authentication down. Absent config is a full no-op (dev/test/Helpdesk take no Azure dependency). Adopted by ADC (Identity service + UI.Web, container on the existing avatar storage account) and, since 2026-08-13, by Store's UI host, which provisions its own dedicated private storage account gated behind a dataProtectionStorageReady parameter (flipped true in production).
070 Fail-fast configuration contract Every settings section binds through AddOptions().Bind().ValidateDataAnnotations().ValidateOnStart() so a misconfigured host refuses to boot instead of failing at first use: 16 framework registrations (twelve of them in the Infrastructure package alone) plus exactly two per service host, identically across all eight hosts in the three application repos, with six framework bindings deliberately off the chain because an absent section there is a working default rather than a misconfiguration. IOptions<T> of the concrete settings class is the one resolution surface at every layer, framework consumers included (ForgotPasswordHandlerBase takes IOptions<PasswordResetSettings> like the rest), so there is no second alias type to keep in sync with the class it wraps. Nothing gates the chain today (recorded trade-off). Revised 2026-08-31: the two host-owned sections (ApplicationSettings, ModulesSettings) come from the shared AddModuleHost call rather than from an inline chain repeated in every host. Revised 2026-09-03: AddInfrastructure calls AddCaching, so eleven validated chains reach every host and four (scheduler, audit trail, multi-tenancy, push) are the genuinely opt-in ones; the framework carries eight recorded exceptions.
071 Barcode scanning + QR display capability Two halves of one feature split by what each actually depends on. Rendering a QR needs no device, so QrCodeImage is a plain shared component (QRCoder's managed PNG path as a base64 data URI, [EditorRequired] AltText, a framework-owned QrErrorCorrectionLevel so consumers are not pinned to the generator). Reading one needs a camera, so IBarcodeScannerService (IsSupported + ScanAsync, never throws, null covers cancelled/denied/unsupported) is an ADR-042 capability with a TryAdd null fallback and a MAUI implementation over ZXing.Net.MAUI (MIT, modal scan page, 2D formats, Android/iOS). The native half is opt-in per head (UseCommonBarcodeScanner(cancelText, cameraDescription)) and deliberately NOT folded into UseMauiDeviceCapabilities, so a head that never scans ships neither the handler nor a camera permission declaration; Revised 2026-08-31: the two page strings are supplied as delegates (UseCommonBarcodeScanner(Func<string>, Func<string>)) invoked once per scan at the moment the page is built, so a runtime culture switch re-localizes the scan page, and the QR half stays a managed, WASM-safe shared component that takes no device dependency at all.
072 QR badge check-in + points gamification (ADC) The badge QR carries an opaque server-verified Guid (mmca-adc:badge:{credential}), not a JWT or HMAC: the scanning device is online anyway, while a signed token leaks its claims to a screenshot and cannot be revoked without a second store, where a GUID is revoked by one Regenerate(). Organizers scan attendees as the primary path, with two recorded self-service exceptions since 2026-08-14 (attendee-scanned sponsor and room QRs, each behind its own feature flag and capped once-per-subject by the same filtered unique index), and TicketLeap keeps owning arrival check-in, so ADC's scope is session-first: one CheckIn aggregate with an Event/Session/Sponsor scope, guarded by three filtered unique indexes. Points are an append-only ledger whose unique (UserId, ActivityType, SubjectKey) index is simultaneously the redelivery-idempotency guard and the anti-farming rule (one award per subject, so five questions in a session earn once). Awards ride events that already exist (check-in and feedback via the outbox, question-asked via an in-module domain event, at-most-once and accepted as such); rule values live in config and a 0 disables a rule. The ledger stays ADC-local with the extraction path pre-paid (IPointsAwarder names no conference concept), and the leaderboard is opt-in with a display-name snapshot and a database-side grouped SUM (revised 2026-08-31).
073 Multi-tenancy (shared-schema + DB-per-tenant) A second named EF query filter, "Tenant", composes by AND with the existing "SoftDelete" filter and embeds the executing context as a constant, so one cached model per source serves every tenant with the tenant id as a SQL parameter (null tenant = system context sees all). ITenantContext resolves claim-then-header behind TenantResolutionMiddleware (wired after UseAuthentication, RequireTenant fail-closed), a dedicated TenantSaveChangesInterceptor stamps writes and refuses cross-tenant ones, and DB-per-tenant is a per-tenant PhysicalDataSource connection-string override under the same DataSourceKey. The outbox is drained per (source, tenant) pair with no OutboxMessage schema change, and the caching decorators prefix keys with the tenant. Helpdesk is the reference adopter; ADC and Store stay single-tenant. Revised 2026-08-31: the repository's soft-delete-inclusive reads drop only the named SoftDelete filter, through one shared field at eight call sites, so widening a read to deleted rows never drops the tenant filter with it.
074 Recurring job scheduler (persistent cron) Durable, multi-replica-safe scheduled work built on the OutboxProcessor claim-lease idiom (ExecuteUpdate over LockedUntil/LockToken) rather than Hangfire or Quartz: an IScheduledJob contract resolved scoped per execution, a ScheduledJobEntry store on the Default source only, Cronos for cron parsing, and a BackgroundService runner that smart-waits to the earliest NextRunOn through TimeProvider. Missed schedules run once and then advance (no catch-up storm). Opt-in via AddScheduledJobs; the framework's own first job is ADR-075's retention purge. Revised 2026-08-31: the scheduler carries its own instrument set, one tagged counter plus two histograms, distinct from the outbox's two counters, one histogram and two observable gauges, so the two subsystems that share the claim-lease idiom are still told apart in a query.
075 Audit trail (field-level change history) A third SaveChangesInterceptor, registered last so it diffs freshly stamped values, writes per-property AuditTrailEntries in the same transaction as the data (the outbox precedent), per relational source. Opt-in twice: AddAuditTrail plus an IAuditedEntity marker per entity. [Pii] properties record PiiRedactor.RedactedToken at capture, never clear text. The entry is deliberately not IAuditableEntity (no self-stamping, no soft-delete filter, like OutboxMessage); retention is a scheduled purge (ADR-074), and v1 ships IAuditTrailReader with no controller.
076 Data-subject export (DSAR) contract Hoists the export idiom ADC and Store each wrote by hand: ExportUserDataHandlerBase mirrors DeleteUserHandlerBase (same ownership gate through UserOwnershipRule, same privileged-role hook), fans out to registered IUserDataExportSection implementations, and assembles a versioned JSON UserDataExportDTO; the shipped endpoint is an abstract [FeatureGate]-d DataExportControllerBase a consumer subclasses (adopted by both: ADC and Store each ship a UsersDataExportController deriving from it, and both Identity services enable Privacy.DataExport). Per-section failure degrades to Available = false rather than failing the package, because a data-subject request is a deadline with a legal obligation attached. Consumers become thin subclasses re-registering their existing projections as sections (ADC's check-in and points coverage already landed with ADR-072). The PiiEntitiesAreExportable fitness rule is deferred.
077 HybridCache substrate (amends 026) An opt-in third Tier 1 substrate: AddCommonHybridCache swaps ICacheService to HybridCacheService (L1 in-process + L2 distributed). The structural decision is the disjoint {prefix}hc:{key} keyspace, which makes the two-serialization-formats-in-one-keyspace failure (ADR-026's WRONGTYPE incident) impossible rather than unlikely, including mid-rolling-deploy; prefix eviction runs both patterns through the migration window. IncrementAsync bypasses L1 on both legs to keep today's counter semantics, the caching decorators keep their own stampede logic (they cache only successful Results), and RemoveByTagAsync is deliberately deferred. Default path with no call is byte-identical to before. Scope note (2026-08-18): this record is Tier 1 only. The cross-service output-cache eviction of the same date is a Tier 2 change and lives in ADR-026's Revision (2026-08-18). Revised 2026-08-31: the substrate's TTL policy is bound from the Cache settings section rather than fixed in code.
078 CSV export as a dedicated endpoint "Export what you filtered" ships as [HttpGet("export")] on EntityControllerBase, not as content negotiation on the existing URLs: PublicEndpointOutputCachePolicy does not vary by Accept (so a CSV request could be served cached JSON) and ReturnHttpNotAcceptable=false makes a negotiation failure silently return JSON. Because the query pipeline caps unpaged reads at 1000 rows and has no IAsyncEnumerable path, the endpoint page-loops at MaxPageSize and streams up to MaxExportRows (default 100,000), truncating with a trailing # export truncated at N rows comment row, since the headers are already flushed (the one export header, X-Export-Row-Limit, announces the ceiling up front). RFC 4180 writer in-house (no CsvHelper), camelCase headers matching the JSON field names, and no member added to IEntityControllerBase (breaking for explicit implementors). Revised 2026-08-31: the body carries two trailing markers rather than one (truncated, and incomplete), and row scoping comes from the base's GetReadSpecificationAsync hook, the same one all five read actions use, so an export cannot return rows the list endpoint hides.
079 Shared HTTP middleware pipeline UseCommonMiddlewarePipeline fixes one middleware order for every REST/gRPC host (exception handler -> correlation id -> localization -> forwarded headers -> gRPC-exempt HTTPS redirect -> compression -> routing -> CORS -> authentication -> tenant resolution -> rate limiter -> soft-deleted-user -> authorization -> output cache -> JWKS/OIDC -> controllers), with the load-bearing adjacencies commented in code (auth before the limiter and before tenant resolution, forwarded headers before both). Adopted by all seven ADC/Store service hosts, Helpdesk and the template; conditional middleware registers unconditionally and stays inert by config. ADRs 019, 047 and 073 each cite the method as where their middleware sits; this record is the first to decide the order itself (ADR-014 is the decorator-order sibling). Since the 2026-08-21 revision the order is data (named steps via MiddlewarePipelineBuilder), a configure overload inserts/replaces/removes steps by name with the load-bearing adjacencies re-validated at startup, and MiddlewarePipelineOrderTestsBase freezes the sequence in the test tier; the gateways and Blazor UI hosts sit deliberately outside it.
080 Rollout + automatic revision rollback Both consumer deploys are single-revision rollouts whose last step is a post-deploy smoke gate asserting expected status codes (health, JWKS, a public read, exactly 401 on protected routes); on failure it walks every container app back to its previous revision (az containerapp revision copy) and escalates loudly when a rollback itself fails, since that splits the fleet across revisions. Rollback is revision-only by construction: schema is never reverted (no down-migrations, ADR-030), which is the premise ADR-057's expand/contract gate is built on. Best-effort loop, hand-maintained app list, and a previous-revision selector that can overshoot by one release are recorded trade-offs. Revised 2026-09-03: the smoke gate is two-tier, a revision activation gate (newest revision Healthy, Running, 100% traffic, polled 30 x 20s) before the HTTP probes, closing the stale-code blind spot behind the 2026-08-28..09-02 incident; the rollback selector picks the newest active, Provisioned, Healthy revision other than the newest and skips rollback when the newest is already serving, which closes the positional-selector trade-off; both repos wait on the same ten needs, and a non-gating cache-purge step follows the gate.
081 Cost baseline as a deploy gate A read-only cost-guard workflow (cron + dispatch + workflow_call) asserts the production footprint still matches its cost baseline: every Container App at maxReplicas 2 or below and every SQL database on the accepted tier (ADC Basic only; Store Basic or Standard). It sits in deploy.needs, so an un-reverted manual surge blocks the next production deploy; it never mutates anything. Sibling of ADR-060/062/064 (ADR-064 only enumerates it in passing). Recorded defects: the printed remediation is circular now that the gate blocks the deploy it recommends re-running, Store's tier check passes any S-series scale-up, and an unreadable replica count scores ok (fails open). Revised 2026-08-31: the one write the gate performs is to the runner's own az CLI configuration, never to Azure.
082 Two-tier cross-origin posture Service hosts get named allow-listed CORS policies from one AddCommonCors call (origins from Cors:AllowedOrigins, five explicit methods, four headers, credentials), selected per environment inside the shared pipeline between routing and authentication; the gateways get a DEFAULT policy that restricts only origins and passes any header/method, because a reverse proxy must forward arbitrary client headers. Both tiers carry a Development allow-any-origin branch under an S5122 suppression. Origins are config, empty by default, injected at deploy time (gateways only in production). No test asserts an emitted Access-Control-* header, and Cors:AllowedOrigins is deliberately outside the ADR-070 fail-fast chain.
083 CRUD lifecycle event taxonomy One EntityChangedEvent<TId> base (a DomainEntityState discriminator plus the entity id) replaces per-entity Created/Updated/Deleted triples: the factory raises Added, mutators raise Updated, Delete() raises Deleted, and handlers filter on State. Business state-machine transitions (OrderPaid, ShoppingCartCheckedOut) deliberately keep their own event types off this base. The discriminator rides integration events as a frozen wire field (ADC's points handlers branch on it). Re-counted 2026-08-31: 32 lifecycle events across the three apps follow the shape, 14 of them derived from the shared base (plus 2 in the ECommerce sample), so the base is the convenience and the shape is the convention, and nothing enforces the taxonomy structurally: it holds by review convention rather than by a fitness rule (recorded trade-off). ADR-003 decides dispatch and ADR-010 versioning; neither decided the taxonomy.
084 Stripe webhook ingress contract The inbound-from-a-third-party leg of the delivery family (ADR-003 out, ADR-021 broker in, ADR-054 the backstop): an anonymous raw-body POST verified by Stripe-Signature, whose status code encodes ACCEPTED-vs-PROCESSED rather than success/failure. 400 goes back only when the event cannot be accepted at all (five rejection codes as of 2026-08-31, two of them shape rejections the command validator raises ahead of the handler), because rejections make Stripe retry and eventually disable the endpoint, silently stopping every payment update (the incident that motivated the record); post-acceptance processing failures log and return 200, with ADR-054's reconciliation sweep as the backstop. A startup BackgroundService self-registers the endpoint with Stripe, deletes only its own Auto-registered by MMCA stale endpoints, and holds the freshly minted signing secret in a volatile singleton while logging it at Critical for an operator to persist. Store Sales only (the ADR-072 single-module precedent). Revised 2026-08-31: the action carries an explicit [NonIdempotent] justification under ADR-017's gate, since Stripe's own retry, not an Idempotency-Key, is what the contract is built around, and the self-registering endpoint is marker-scoped. Revised 2026-09-03: the Stripe types live under Payments/Stripe/, and the /Payments prefix is exempt from the gateway edge rate limiter (GatewayRateLimiting.BypassPathPrefixes) so a Stripe retry storm is never answered with 429, which widens the anonymous-endpoint trade-off.
085 Identifier type aliases revisited (revisits 048) The wrapper-struct alternative ADR-048 deferred is re-evaluated, priced and deferred again, now against named triggers instead of open-endedly. The exposure is measured rather than asserted: 44 aliases across 10 files in the four repos, 43 of them resolving to int, so the compiler sees one identifier type with 43 synonyms and the bite lands on cross-module scalar references (ADC's CheckIn constructor takes two different UserIdentifierType arguments that transpose silently). The migration is priced at 3,192 occurrences across 1,001 files in the four Source trees, tests excluded, on a lockstep-released package family, which is what keeps the aliases. Three triggers re-open it: a production defect traced to a transposition, a greenfield fifth consumer, or a materially growing cross-module reference graph. Buys no safety: it converts a blind spot into a priced deferral.
086 Process manager deferred (relates to 054) A documented deferral, shipping no code: the shape a durable multi-step workflow coordinator would take (a MassTransit v8 saga state machine, durable per-instance correlation state in the owning service's own database, per-instance deadlines instead of ADR-054's fixed-interval sweep, compensating transitions calling the same guarded domain methods), the licensing pin that fixes the technology choice (MassTransit held at 8.5.10 because v9 needs a commercial license), and the trigger: build it when the first workflow appears with three or more steps across two or more services, state that does not fit one aggregate, and at least one per-instance deadline. Verified absent today (no MassTransitStateMachine / ISaga in any repo). ADR-054's compensation plus outbox retries suffice until then, and its sweep stays underneath a coordinator rather than being replaced by one.
087 Broker poison-message handling (amends 009) Three scoped fixes for the two broker failures retry cannot answer. Second-level redelivery is transport-aware and the asymmetry is the decision: MessageBusSettings.EnableDelayedRedelivery defaults to false because RabbitMQ needs the rabbitmq_delayed_message_exchange plugin the Aspire dev container lacks (a default-on flag would break every developer's first F5 at bus start), while Azure Service Bus applies UseDelayedRedelivery without consulting the flag because it schedules natively; intervals default to [60, 600, 3600]. FaultIntegrationEventConsumer<TEvent>, auto-registered by RegisterIntegrationEventConsumer (opt out with registerFaultConsumer: false), makes an exhausted message visible with one Error log and a counter, and deliberately never replays it. A new meter MMCA.Common.Broker carries broker.fault.count and broker.circuit.open.count. A Polly circuit breaker wraps only the outbox broker publish (0.5 failure ratio, 10 minimum throughput, 30s sampling, 15s break, no retry paired since the outbox is the retry); BrokenCircuitException takes the normal re-lease path and differs only in observability. A per-query database breaker is recorded as rejected: it does not compose with EF's EnableRetryOnFailure execution strategy, so the EF retry posture stands.
088 Gateway edge responsibilities (extends 019) The three cross-cutting behaviors the edge owns, and the three it declines. ADR-079 scoped the gateways out of the shared service pipeline, correctly, and that left correlation, rate limiting and readiness unowned at the one process every request passes through. The edge kit ships in MMCA.Common.Aspire (a YARP host references nothing else): GatewayCorrelationMiddleware ensures X-Correlation-ID and writes it onto the forwarded request so the downstream adopts it instead of minting a second id, and is context-free by construction (its only dependency is the RequestDelegate); AddGatewayRateLimiting runs a per-client-IP fixed window (120/60s) that includes anonymous callers, chained with a global concurrency cap (200), deliberately inverting ADR-019's anonymous exemption because the output cache that justified it lives behind the proxy; AddGatewayDownstreamHealthChecks probes each downstream's /alive on the Ready tag only, so a downstream outage stops traffic without restarting a healthy gateway. Bypasses are two-tier: /health, /alive and /.well-known unconditionally, plus configurable prefixes for the Stripe webhook (a 429 makes Stripe disable the endpoint) and SignalR hubs. Unknown IP fails open. Declined and recorded with a trigger: edge JWT pre-validation (ADR-004 keeps sole validation authority; two validators are two truths across a key rotation). Settings honor ADR-070 on both construction paths: the configuration overload binds through ValidateDataAnnotations().ValidateOnStart(), and because the limiter closes over an eagerly-bound copy (so a caller can bypass the options pipeline entirely), the shared overload also runs Validator.ValidateObject at registration, making the [Range] bounds load-bearing rather than decorative. Trade-offs recorded: the closed-over copy means no IOptionsMonitor reload (a limit change is a restart); the limiter is per replica; nothing gates adoption, though both consumer gateways wire all three registrations today. Revised 2026-08-27 (v1.163.0): the framework now ships a dedicated MMCA.Common.Gateway package beside the Aspire kit, retiring this record's one-package premise (a gateway host takes both: Aspire for host middleware, Gateway for YARP's own extension points). AddMmcaGateway composes per-route limiter policies, a cluster-profile config filter owning each cluster's HTTP version policy, a health-check-defaults filter, and one transform; it registers services and maps nothing, and deliberately does not load the route table, leaving LoadFromConfig and MapReverseProxy to the host so ADR-089's consumer-owned table stays consumer-owned. A fourth section records what the edge delegates, as accepted decisions rather than gaps: bearer validation to the backends (the gateway forwards the header on YARP's default copy; nothing in the package or either host calls AddJwtBearer, and adding it would give the gateway the key-discovery configuration that would turn an Identity outage into a Gateway outage), load balancing to ACA ingress (every cluster fronts exactly one service-discovery destination, no LoadBalancingPolicy exists anywhere, and both repos pin single-destination as a test invariant, since a second would put two schedulers in disagreement about which replica is healthy), and proxy-hop retries to client-side resilience (the Idempotency-Key is minted client-side and appears nowhere at the proxy, so a proxy retry would be a replay with nothing attached to make it safe). Active destination probing ships available but off by default (an extra probe per destination per interval is real traffic; passive TransportFailureRate checking is the on-by-default counterpart because it watches forwards already being made) and both consumers enable it at 30s against /alive, chosen over /health so a downstream's rolling deployment is not read as an outage. The one decline narrowed: "no request rewriting" becomes "no path or body rewriting", because the package stamps X-MMCA-Route / X-MMCA-Cluster, a fact only the proxy knows. New costs: two different /alive probes now exist with different budgets and different consequences (gateway readiness vs destination ejection), and the delegations hold only while facts nothing enforces stay true. Revised 2026-08-31: the edge kit is composed through AddMmcaGateway from MMCA.Common.Gateway in both consumer gateways, each taking the package at the same lockstep version as the rest of the family (ADR-016).
089 Gateway topology owned by configuration (amends 008) The route table is data. Both gateways previously built it by hand in code (26 MapForwarder calls in ADC, 10 in Store), and the argument for moving was that one table was described three times in one repository and the three already disagreed: ADC registered 15 conference forwarders, its comment said 16, and its RouteMapTests pinned 23 of 26, leaving /Sponsors, /CheckIns and /Points ungated; Store had no route test at all. What is genuinely elsewhere is not a route table and must not become one: the AppHost and bicep hold service address books (name to URL, no path prefixes). Decision: ReverseProxy configuration is the single route source, the AppHost keeps dev orchestration and bicep keeps deployment topology, RouteMapTests becomes a drift gate in both repos, the per-destination HTTP version policy (ADR-012 profiles) moves into cluster config, and the total activity timeout Store was missing is declared once, as the shared MmcaGateway:ClusterRequestDefaults value the cluster profile merges into every cluster, with RouteMapTests pinning that no cluster declares its own (revised 2026-08-31). Shipped 2026-08-18 in both consumers, with two recorded residuals, both closed 2026-08-27: Store ported ADC's two IProxyConfig completeness facts (so an added route or cluster now fails there too, and the same comparison doubles as the drift gate on the shared cluster profile), and both gateways declare their cluster request profiles in their own appsettings.json under the MmcaGateway section the shared package binds, which is also what replaced ADC's hand-written Http2ForwardingConfigFilter and Store's HTTP/1.1 overlay. What converged them is worth noting: a shared settings shape is a stronger force than either repo noticing the other's diff. Trade-offs: the compiler stops helping (a bad cluster name becomes a runtime 502, which is why the test is part of the decision), catch-all matching semantics shift, and a JSON table reviews less well than a code diff.
090 Event upcaster registration extension point (completes 010) The consumer-side half of ADR-010 becomes a mechanism: IEventUpcaster<TSource, TTarget> (a pure payload mapping from a retired contract to its successor) registered per module via AddEventUpcaster<...>() (TryAddEnumerable idiom), assembled into a chaining EventUpcasterRegistry that walks V1 -> V2 -> V3 by declared target type, preserves MessageId/DateOccurred across every hop (inbox dedup keys survive by construction), and fails startup, naming the offenders, on duplicate sources, self-maps or cycles. Both delivery paths consult it: DomainEventDispatcher upcasts before selecting integration handlers (covers monolith mode and pre-upgrade outbox rows), and broker hosts drain a retired type via the dedicated RegisterUpcastedIntegrationEventConsumer<TOld>(), so handlers are written once against the newest contract. Two new fitness functions on EventConventionTestsBase (unique source types; target SchemaVersion strictly higher) are inherited by every consumer tree with no edit. Deferred and recorded: outbox type-name aliasing for prematurely deleted types, and producer-side rewriting of old rows. Revised 2026-09-03: outbox type resolution is [EventName]-aware (EventNameResolver storage name, then a declared-name lookup when Type.GetType fails, ADR-003), so a renamed or relocated event stays resolvable; RegisterUpcastedIntegrationEventConsumer also registers the fault consumer by default.
091 Cache-backed password reset (extends 029/032) The reset credential is a cache record, not three columns on the user row and not a self-contained signed payload: single use is unimplementable without a store anyway, and TTL plus attempt cap are native to the cache, so the feature reached two production apps with no migration. IPasswordResetTokenService mirrors LoginProtectionService down to the address normalization (the load-bearing part: raw-input keys give User@x.com and user@x.com independent tokens for one account), and adds 256-bit tokens, SHA-256 at rest, FixedTimeEquals, one live token per address, and a wrong guess that rewrites the record with its remaining lifetime so guessing cannot extend the window. The anti-enumeration contract lives in ForgotPasswordHandlerBase, whose every path returns success (unknown address, throttle, failed send), with ResetPasswordHandlerBase collapsing every rejection into one Auth.InvalidResetToken 401; a subclass cannot leak the distinction by accident. PasswordResetAuthControllerBase is an additive sibling of AuthControllerBase (both apps' AuthController already occupies that inheritance chain) routed to the same Auth prefix, so no gateway route changes; both actions are AllowAnonymous + [Idempotent] + auth-ip. Email is composed in the handler behind overridable hooks (link and raw token, because the MAUI head cannot deep link), English only, fire-and-log with no outbox. Residuals recorded: cache eviction kills live links, and the duration of a registered-address request still correlates with existence, so the timing oracle the always-202 contract does not close is named rather than implied.
092 Core Web Vitals as a shipped test contract and deploy gate The client-side complement of ADR-060: a benchmark measures allocations in one process and k6 measures what the server returned, and neither reaches the browser. WebVitalsCollector in MMCA.Common.Testing.E2E installs PerformanceObserver hooks as a Playwright init script so they exist before first paint (no third-party JS, no network egress, a missing entry type leaves that metric at 0 rather than throwing), and WebVitalsBudget defaults to the Core Web Vitals good band: LCP 2500, FCP 1800, TTFB 800 ms, CLS 0.1 and a single-interaction INP-sample ceiling of 500. An absolute client-side ceiling is defensible where ADR-060 refused an absolute nanosecond count, because the good band is externally defined and the measured values sit 4x to 30x below it; a breach throws naming the metric, the measured value and the page, and a zero INP is skipped rather than read as a pass. Both deployed apps assert the shipped defaults over four surfaces each, calibrated against recorded maxima with the run id beside the ceiling, and because e2e.yml runs the whole project with no filter the budget rides the existing chromium-only e2e-gate in deploy.needs at zero marginal CI cost, the lever ADR-063 used for accessibility. The framework gallery runs the same collector under three looser local constants as a catastrophic-regression backstop. Recorded costs: the gate is ui-scoped and may legitimately skip, it never runs on a pull request, CI pins the UI to InteractiveServer so the numbers are not production's InteractiveAuto, a marginal breach can be retried away, LCP and CLS are Chromium-only (two of MMCA.Common's three engines assert them against nothing), coverage is a hand-picked page list, and the green-run artifact is discarded with the runner. MMCA.Helpdesk adopts none: it pins the package and has no E2E project, the same gap ADR-063 records.
093 Container image build and runtime posture Eleven Dockerfiles (six ADC, five Store) uniform by copy rather than by a shared base, encoding three decisions never written down and two properties left at "what the default gave us". Decided: the GitHub Packages credential is a BuildKit --secret mount read into a shell-local variable, never an ARG promoted to ENV that would land in the layers, the build cache and docker history; there is deliberately no separate dotnet build stage, because the SDK infers a RID for ReadyToRun so publish never reused build output and every image compiled twice (measured at about 75 seconds per image, dated in the file with its CI run id), and nothing is lost since analyzer gating runs inside publish; and PublishReadyToRun=true on the nine service and gateway images and only those, because their Container Apps run on 0.25 to 0.5 vCPU where first-request JIT is a visible tail on every deploy and scale-out. Every image publishes UseAppHost=false, starts through ENTRYPOINT ["dotnet", "<Host>.dll"], and exposes the ADR-012 REST and h2c ports, with liveness left to the Container Apps probe. Recorded as undecided rather than as decisions: the base image is a floating 10.0 tag while the application layer is pinned by commit sha (so the code in a revision is exactly identified and the runtime under it is not), and all eleven containers run as root with no USER directive. No image scanner runs in either pipeline, so a base-layer CVE is reported by nothing that gates a deploy, and that combination is named as the one worth closing first. Revised 2026-09-03: after the 2026-09-02 right-sizing all six ADC container apps run at 0.25 vCPU / 0.5 GiB; the earlier 0.5 vCPU / 1 GiB split is a one-line revert option, not a live allocation.
094 Client-side entity data-access contract The calling half of ADR-034, decided long ago and recorded only now, because a new module author copies its three choices without knowing they were choices. One hand-written base hierarchy in MMCA.Common.UI, not a generated client (no Refit, Kiota or NSwag in any of the four repos): AuthenticatedServiceBase owns the named APIClient, the bearer attachment, the 90-second transport timeout that keeps the BCL's uncoordinated 100-second default from cutting a call off mid-policy, and the retry policy; EntityServiceBase<TEntityDTO, TIdentifierType> emits exactly the bracketed filters[Property].operator pairs the server binder parses. User-facing retry lives in the client base rather than in ADR-009's server-to-server handler: three retries at 2s/4s/8s plus jitter, 5xx except the permanent 501 and 505, plus 408 and 429. The Idempotency-Key is minted client-side and set as a default header on the single HttpClient that serves every attempt, which makes ADR-017 dedup structural rather than a rule to remember; only AddAsync supplies one. Revised 2026-08-27 (v1.164.0): the dispatch returns a Result and throws nothing for a server answer. SendRequestAsync wraps its send in HttpResultExecutor and hands the response to ProblemDetailsResultReader, so a page still sees the business reason instead of "500" but also keeps the ErrorType, letting it turn a 404 into an empty state and a 401 into a redirect instead of pattern-matching message text; the earlier arrangement, which extracted the domain wording and rethrew it as a DomainInvariantViolationException before falling back to EnsureSuccessStatusCode, is gone and its helper deleted rather than deprecated (ADR-013). The same layer owns the list-page half, DataGridListPageBase<TDto> (server-side paging through MudDataGrid ServerData, cancellation-token churn, a LoadFailed flag separating error-with-retry from genuinely empty, viewport-driven mobile cards, and list state persisted to URL, session storage and memory with the URL as source of truth), inherited by nineteen types. Trade-offs: no generated client means query-vocabulary drift fails the call rather than the build, retry budgets stack across hops so the shared per-hop count is pinned to one after full budgets everywhere turned a brownout into an up-to-16x storm, the policy is static and not configurable, and ChildEntityServiceBase calls get neither retry nor a key. Revised 2026-08-31: GetByIdAsync takes (id, includeChildren, CancellationToken) and answers a missing entity with a NotFound failure rather than a default value, and ChildEntityServiceBase is recorded with both PostAsync overloads and with a missing join row answering NotFound instead of false.
095 Uniqueness under soft delete (filtered unique indexes) The half ADR-005 never addressed: the query filter says a soft-deleted row does not exist, but a unique index still counts it, so deleting a record blocks re-creating it with an error whose conflicting row is invisible to the user. Making it a per-configuration HasFilter string would be the kind of rule that gets forgotten, so it is a convention instead: SoftDeleteUniqueIndexConvention, registered once in ApplicationDbContext.ConfigureConventions and therefore reaching every module, database and consumer repo through ADR-006's one-context-class-per-engine base, filters every unique, unfiltered, non-owned index on an IAuditableEntity at model finalizing, with nothing to opt into per entity. Revised 2026-08-26: a hand-authored filter is now extended rather than skipped (the soft-delete clause is appended with AND, in the same order HasSoftDeleteFilter(additionalFilter:) produces, and idempotently: a filter already constraining the column is recognized through normalized quoting and left alone), because skipping left precisely the partial-unique indexes a model bothered to hand-author as the only ones a soft-deleted row could keep blocking. The consequence is one more schema move: the next consumer migration drops and recreates each pre-filtered unique index once, with the same ADR-057 override. There is correspondingly no opt-out any more. Both the automatic path and the public HasSoftDeleteFilter opt-in run through one predicate builder, so they cannot disagree about which column carries the flag or about engine quoting (brackets for SQL Server, double quotes otherwise); Cosmos is a no-op on both. Trade-offs: adopting it moved consumer schema invisibly from the entity configuration (ADC's v1.120.0 migration dropped and recreated two unique indexes and needed an ADR-057 override, while Store's and Helpdesk's of the same sweep carried only the outbox columns, so only the generated migration says which), duplicates among deleted rows are now permanently legal (an ignoreQueryFilters report can see them, and any restore path has to handle the collision at undelete time rather than at delete time), and the predicate is not visible where the index is declared.
096 Best-effort side-effect contract Five records each made post-commit follow-up work non-fatal for their own feature (ADRs 024, 026, 054, 076, 091) and each was right locally; none decided the policy, which left hand-rolled catch (Exception) blocks each picking their own severity, their own treatment of cancellation and their own decision to count nothing. BestEffort.ExecuteAsync(operation, logger, action, cancellationToken) in the Application layer fixes it once: the action is awaited rather than left as an orphan task racing the response, a failure produces exactly one Warning and exactly one besteffort.dispatch.failed increment tagged by a low-cardinality operation name on its own MMCA.Common.BestEffort meter (its own meter so an operator can drop or keep it independently of the RED metrics), a blank operation name or a null logger throws because the helper swallows the side effect's failures and never its caller's bugs, and an OperationCanceledException raised on the caller's own token is rethrown so an orderly shutdown stays a shutdown while an inner timeout is swallowed like any other failure. Post-commit callers pass CancellationToken.None on purpose, since the write has already committed. Re-counted 2026-08-31, adoption is seven call sites: six ADC Engagement broadcasts plus the framework's own TryEvictTagsAsync output-cache eviction helper. Two swallows deliberately stay hand-rolled and say so in code: Store's AddVariantHandler, whose loss is unrecoverable and logs at Error with the ids an operator needs to fix it by hand (evidence the work is not best-effort), and the framework's own OutputCacheEvictionHandler, which cannot reach Application from the API package without a layer-crossing reference. Recorded costs: nothing gates use of the helper (the only inventory is a search), the Warning carries the operation name and the exception and nothing else, the counter is failure-only and alerts on nothing, the meter name is a duplicated literal, and a swallow is still a loss (a cache entry heals on its TTL, a lost broadcast never replays).
097 Multi-device refresh sessions Replaces ADR-050's storage model (one plaintext refresh-token column on the user row) while keeping its rotation and reuse-detection policy: refresh tokens become rows in a RefreshSessions table, one per signed-in device, held as an unsalted SHA-256 hex digest so a database read holds digests rather than mintable credentials (unsalted deliberately, because every lookup is BY hash and the input is 64 bytes of CSPRNG output, not a guessable password; the hex encoding is a documented contract so a consumer's data migration can hash existing tokens in place and keep everyone signed in). Rotation revokes the presented session through a conditional UPDATE the store arbitrates (TryRotateAsync), so a concurrent second refresh of the same token loses and is answered as a replay, and records its successor in ReplacedByTokenHash, so a replayed token lands on a revoked row instead of on nothing: that is the reuse signal, and it revokes the user's whole live family, while an unknown hash fails alone (revoking on it would let anyone sign a user out everywhere by posting a random string) and an expired row is an ordinary end of life. Sign-out has both scopes (RevokeTokenAsync with the device's token, RevokeAllSessionsAsync for everywhere; the body-less POST auth/revoke cannot name its own device so it signs out everywhere), a configurable cap (RefreshSessions:MaxActiveSessionsPerUser, default 10) evicts the oldest live session rather than refusing an eleventh sign-in, and optional IP/user-agent capture is informational only, never part of a validation decision. Mapping is opt-in per data source (RefreshSessions:Enabled plus a DataSourceName matched against the context instance's physical source, the scheduler-table precedent, so the table lands in exactly one database instead of every module's migrations; design time mirrors it with DesignTimeDbContextOptions.EnableRefreshSessions in the Identity migrations project only). Token side: sub is the single carrier of the user id (the duplicate custom claim is gone) and RS256 tokens emit the JWKS kid. Revised 2026-08-27 (v1.164.0), making a per-device session visible and finite, all additively: an access token names its own session through a sid claim stamped at issuance and at rotation (on the successor, not the predecessor) by a private pass-through ITokenService decorator rather than by changing the abstract CreateAccessToken hook every consumer overrides, so existing subclasses emit it with no edit; the session is opened before the token is minted, because a token cannot name an id that does not exist yet. GET auth/my-sessions and POST auth/revoke/{sessionId:guid} ship on AuthControllerBase, returning a RefreshSessionSummaryResponse that deliberately omits the token hash and its successor, with IsCurrent computed server-side from the caller's own sid; revoking another user's session is indistinguishable from revoking one that does not exist, and revoking an already-revoked one succeeds without writing, because a double click leaves the caller in the state they asked for. The shared /profile/sessions page comes free with the framework router's AppAssembly (no registration), marks the current device with a text chip rather than a colour, offers no revoke on the current row (ending your own session reads as a broken sign-out), and renders a failed load inline rather than as a toast. RefreshSessionCleanupService ages the table out, deleting rows that stopped being usable more than RetentionDays (default 30) ago, measured from the revocation if revoked and otherwise from the expiry, in one ExecuteDeleteAsync, logging its count even at zero, registered on Enabled alone. Three original costs are retired (no sweep, unread IP/user-agent columns, no device-listing endpoint); the new ones are that retention also bounds reuse detection (deleting a revoked row turns a future replay from a theft signal into an unknown value), that a subclass minting from its own ITokenService silently emits no sid, that nothing validates sid so it is a hint and not an authorization input, and that the page revokes with no confirmation step.
098 Aspire for orchestration, not for testing or production dashboards Two standing divergences from the default Aspire path, recorded as decisions rather than left to read as unfinished adoption. Aspire is used on exactly two surfaces: each app's AppHost composes the local stack (containers, per-service databases, broker, services, Gateway, UI) and every host calls AddServiceDefaults / MapDefaultEndpoints. Integration testing stays WebApplicationFactory plus Testcontainers (seven per-service fixtures over SqlServerIntegrationTestFixtureBase, plus a cross-service tier booting three real hosts against Testcontainers SQL Server and RabbitMQ) and DistributedApplicationTestingBuilder appears in none of the four repos, because the hosts read their connection string, MessageBus provider and JWT settings at configure time, before builder.Build() and therefore before a factory's configuration deltas apply, which makes process environment variables the only override channel they honour and forces a strictly sequential boot; and because the AppHost stalls in a non-interactive local shell while coming up fine in CI, where it already is the E2E lane, so an app-model tier would duplicate a CI-only tier and lose the fast loop. One small exception is sanctioned and, as of 2026-08-31, shipped in ADC: a nightly, non-gating AppHost composition smoke test riding the existing cross-service nightly, so the decision is no longer expressed entirely as the negative. Production observability is workspace-based App Insights with priced cost thinning (head sampling at 0.25, a Warning floor for the OpenTelemetry logging provider while Serilog keeps Information on container stdout, the http.client.* and dotnet.* instrument groups switched off at about 65% of AppMetrics ingestion, and a 300-second metric export interval against five-minute alert windows) read through SLO alert rules and a saved workbook, not the ACA Aspire dashboard, which is ephemeral, full fidelity (it would undo the thinning) and has no alerting surface. Costs recorded: nothing below the E2E lane tests the AppHost's own wiring, the environment-variable channel is global and order-sensitive, sampling means a counted request may have no trace, Information logs are off the queryable path, metric signal is delayed by the export interval, and neither absence is build-enforced (this record is convention, guarded only by review).
099 Generic write-side entity commands The half ADR-034 left at create and delete: a generic update, additive end to end. The module writes one member, IEntityUpdateApplier<TEntity, TUpdateRequest, TIdentifierType>.ApplyAsync, which calls the aggregate's own guarded methods, so invariants and events keep exactly one home and the generic handler raises none of its own (ADR-083). UpdateEntityCommand carries id, request and RowVersion and implements two existing markers rather than inventing a path: ICommandWithRequest, so the validator bridge auto-registers a CommandRequestValidator and a module writes only IValidator<TUpdateRequest>, and ICacheInvalidating, defaulting CachePrefix to the aggregate-prefix convention the controller cannot supply. UpdateEntityHandler is three overrides on the existing MutateEntityHandlerBase (load, stamp the ADR-035 token, mutate, save), left unsealed for per-aggregate Includes; CreateEntityHandler is the sealed hook-free form of its base. AddEntityCrud<...>() registers all three verbs closed (Scrutor TryDecorate only wraps concrete service types, so an open registration would resolve undecorated and be invisible to VerifyDecoratorPipeline) and with TryAdd (an aggregate that outgrows one verb keeps the generic pair for the other two), throwing if the pipeline is already sealed. The PUT ships on a new CrudEntityControllerBase, not on AggregateRootEntityControllerBase, whose generic arity would otherwise break every consumer's controllers; it is [Idempotent] + [SupportsIfMatch] and emits the refreshed weak ETag. Costs: the update request is the write contract, the default cache prefix is a convention nothing checks, two controller bases now exist for one resource shape, and no rule stops an applier from writing properties directly instead of calling the aggregate. Revised 2026-08-30 (v1.172.0) with the five surfaces that complete it, all additive: a MutationContext threaded through every hook as a forwarding overload (a typed bag for a value derived while the aggregate is loaded, plus SkipSave(), the idempotent no-op that succeeds with no save, no log and no post-commit hook); MutateEntityPayloadHandlerBase, a third mutate flavor for a command answering with a purpose-built envelope rather than the aggregate's DTO, built in BuildResult from the aggregate and the context; MutateCoreAsync(attemptUnitOfWork, ...), giving the mutate path the fresh-scope retry parity CreateCoreAsync already had; an opened DeleteEntityHandler (virtual HandleAsync split into Includes, AsTracking, LoadAsync, the refusing OnDeletingAsync, LogDeleted), because the two things a delete outgrows are loading the children its cascade must see and refusing on an invariant wider than the aggregate; and two ways to type a command the three-parameter form cannot express, a phantom TApplier discriminator so two verbs share one request DTO (AddEntityUpdateVerb) and an unsealed base command served by a command-aware IEntityUpdateCommandApplier (AddEntityUpdate), with AddCommandRequestValidator as the explicit validator bridge for a closed generic the module scan cannot see. MutateAsync becomes virtual and throws when neither overload is overridden. Domain-verb state machines, sagas and payment flows, the auth verticals and multi-aggregate orchestration stay hand-written by design. Revised 2026-08-31: the generic write side and its five extension points are adopted on ADC and Store main. Revised 2026-09-03: AddEntityCrud also registers the update request's CommandRequestValidator bridge explicitly (v1.177.0), because the module scan cannot see a closed generic constructed at registration time.
100 Outbox resolved from the messaging mode Amends ADR-003: the outbox stops being unconditional and starts being derived from the transport, on the rule ADR-021 already set for the inbox. MessageBus:EnableOutbox is bool? and IsOutboxEnabled resolves an unset value as Provider != InProcess, an explicit value winning in both directions, so a single-process host dispatches every event inside the process that raised it and runs neither OutboxProcessor nor OutboxCleanupService, while a monolith that wants store-and-forward back sets the flag. The gate is at both write points too (InProcessEventBus takes its direct-dispatch branch, DomainEventSaveChangesInterceptor writes no rows), reusing the branch a Cosmos context already took. A broker transport with the outbox explicitly disabled throws at registration, because that combination has no delivery channel at all and would drop every cross-service event silently. Being off is never silent either: a one-shot OutboxDisabledNoticeService names what is not running (no retry for a failed handler, a crash between commit and dispatch loses the event) and the flag that restores it. The EF model is unchanged: OutboxMessages stays configured for every relational provider, so flipping the flag is a restart and never a migration, and a table drop is exactly the contract-phase move ADR-057 exists to keep out of a release. Costs: the default changed for in-process hosts (a monolith relying on the outbox to retry an in-process handler loses that until it sets the flag), the same module now has different durability as a monolith than as an extracted service, the resolved posture appears nowhere in configuration, and every database ships an OutboxMessages table that may never take a row.
101 MMCA.Common metapackage (the Core 6) One PackageReference in place of the six a standard application host always takes (Shared, Domain, Application, Infrastructure, API, Aspire), ordered so the bundle reads as the architecture it installs. It ships no assembly: IncludeBuildOutput=false, with NU5128 suppressed in that project alone because the warning describes exactly the shape a metapackage is meant to produce and TreatWarningsAsErrors would turn it into a pack failure. MinVer versions it off the same tag, so it publishes to both registries in the same run with no workflow change (ADR-053) and inherits lockstep unchanged (ADR-016), which is the property that makes pinning its six dependencies at its own version safe. UI, UI.Web, Gateway, Grpc, Aspire.Hosting and the Testing.* packages stay out (each belongs to a specific project rather than to every host, and UI.Maui is unreferenceable from a net10.0 bundle at all, ADR-042). Costs: an app mixing the bundle with UI or Testing packages has one more entry to sweep rather than fewer, a single reference no longer states which layers a project participates in, the bundle has one shape and cannot be trimmed per consumer, a warning is suppressed rather than avoided, and no application in this workspace consumes it yet, so its ergonomics are asserted rather than demonstrated.
102 PBKDF2-only password hashing Supersedes ADR-032 by deleting the half of it that selected an algorithm from the data. One framework IPasswordHasher, one implementation, TryAddSingleton on a stateless type: PBKDF2-HMAC-SHA512, 32-byte salt, 64-byte digest, 600,000 iterations, FixedTimeEquals compare. VerifyPassword has no branch: it recomputes with the one algorithm and compares, so LegacyHmacSaltSize, ComputeLegacyHash and every HMACSHA512 usage are gone from Source/ in all four repos. The removal was safe because the production credential stores were checked and held no legacy-format rows, and it is now asserted in the opposite direction: a test builds a 128-byte-salt HMAC digest and requires the correct password to fail verification, alongside known-answer tests, reflection pins on the three constants and an IL-shape rule that the type still depends on Rfc2898DeriveBytes and CryptographicOperations. All four call sites (login, registration, change-password, reset-password, seeding) stay in Common bases; neither app's Source/ invokes the hasher. Costs: a legacy row that ever reaches production fails login indistinguishably from a wrong password and needs a reset, with nothing detecting it; the work factor is a compile-time constant, and since verification recomputes with that same constant and stores no per-record parameters, raising it invalidates every stored hash; the format carries no version marker; and verification derives to the stored hash length rather than to HashSize.
103 bUnit component-test tier as a package The tier between unit tests and the browser: decided nowhere until now, since ADR-058 scopes itself to booted-host runtime conformance and ADR-063/092 to the Playwright tier, while ADR-101 mentions Testing.* only to keep it out of the metapackage. MMCA.Common.Testing.UI ships BunitComponentTestBase, which fixes once every choice each consumer UI test tree would otherwise re-derive: bUnit v2 (the xUnit-v3 / Microsoft Testing Platform line) with BunitContext and Render<T> isolated behind RenderUnderTest / RenderAs, so a line change edits one file; MudBlazor services plus the production AddCommonUiFacades call, so a test resolves the ADR-067 IToastService / IAppDialogService facades and exercises the real Mud-backed path; loose JSInterop so components probing JS during render do not throw; a mutable AuthenticationStateProvider driving both the cascading AuthenticationState and pages that inject the provider directly, with a permissive-but-real authorization double and a TestPrincipal factory that writes the id under both sub and NameIdentifier; an open-generic IStringLocalizer so ADR-027 markup renders with no per-test setup; and ConfigureDataGridListPageHost, one helper whose ordering is load-bearing because SetRendererInfo builds and freezes the bUnit provider, so any later registration is silently ignored and the page resolves the framework default instead of the test's double (eighteen call sites use it; fifteen hand-rolled copies preceded it). Adoption is one thin repo-local subclass per project: six consumer test projects across ADC and Store plus Common's own UI tests, all in the gating CI subsets. Costs: loose JSInterop proves nothing about JS and the authorization double answers on authentication rather than policy (both belong to tiers above), the freeze rule is a convention no compiler enforces, nothing requires a UI test project to use the base, the package pulls MudBlazor and Moq into every consumer, and the bUnit plus AngleSharp pins are repeated in three central package files that must agree.
104 Plain enums by default, Enumeration<T> opt-in A bounded set is a plain C# enum unless a member must carry data or behavior. Enumeration<TEnumeration> is the shipped smart-enum base for that second case: public static readonly members discovered by DeclaredOnly reflection and frozen into per-type value and case-insensitive name lookups, Result-returning FromValue/FromName (ADR-013), type-guarded equality that deliberately declines IEquatable<T> (S4035), and a deliberate non-derivation from ValueObject because the sealed-record fitness rule would forbid the static-member idiom. JSON is the member name via EnumerationJsonConverterFactory, registered once in AddAPI because System.Text.Json does not inherit [JsonConverter] from a base type; XML rides the [DataContract]/[DataMember] pair; persistence is EnumerationValueConverter<T> / NullableEnumerationValueConverter<T> over a plain int column, so swapping a CLR enum for a smart enumeration is neither a wire change nor a migration. 33 test methods pin the contract, and adoption is zero: no production type in the four repos or the ECommerce sample derives from it, the only derivations being five private test fixtures, while DomainEntityState (ADR-083) and every other bounded set stay plain enums. Recorded in ADR-037's shipped-tested-unadopted style rather than deleted, because the 18 declarations are frozen in the ADR-015 RS0016/RS0017 baseline. Costs: the ergonomics are asserted rather than demonstrated, the choose-between rule is prose with no fitness function, an enumeration serialized outside AddAPI's options falls back to the default object shape unless it repeats the attribute, and the EF read leg trusts the column (an undeclared value materializes null).
105 Data residency as a build gate The published residency claim stops being prose and becomes an assertion. DataResidencyTestsBase parses the region where a repo actually provisions PII-bearing storage from that repo's own infrastructure source of truth (ADC from the SQL_LOCATION_OVERRIDE:- default in deploy.yml, Store from the single-region sentence in its DR runbook), then fails the build unless PRIVACY.md contains it under whitespace- and case-insensitive comparison, plus a per-repo denylist that blocks a stale or copied region claim from returning. Only ExtractDeployedRegion is abstract; adoption is the two deployed apps (Helpdesk references the package but declares no subclass and publishes no policy).
106 Extension members as the public DI surface The framework's entire public Add* registration surface is written as C# extension(T) blocks inside static classes rather than classic static extension methods, compiled under LangVersion preview in all four repos and shipped to both registries in that form. Measured 2026-09-01 across MMCA.Common/Source: 82 extension blocks in 65 files, 23 of them extension(IServiceCollection services) spread over ten packages. The call site is indistinguishable from a classic extension method, and the compiler emits a classic static M(this T) member alongside each one, which the ADR-015 public-API baselines record next to the extension(...) shape. That doubled baseline is what keeps the exit path mechanical: flatten each block back to static extension methods, with names, parameters and return types unchanged, and no Program.cs line moves. Costs: CA1708 is wrong on every block it flags, so the surface carries type-level suppressions that also silence genuine future hits on those types, and turning a method into a property inside a block is a binary break that looks like a two-word edit.
107 Transaction execution and commit-ambiguity contract ExecuteInTransactionAsync is re-entrant (an inner call joins the ambient transaction rather than nesting), runs the whole delegate under EF's execution strategy with a change-tracker reset per attempt, rolls back on a failed Result exactly as on a throw, and never retries the commit: a commit whose outcome is unknowable escapes as TransactionCommitAmbiguousException naming each physical source as committed, ambiguous or rolled back. Rollback after a commit failure is literally best-effort, deferred in-process dispatch is dropped on every non-success path (the outbox rows are the surviving delivery record), the witness row that would close a partial multi-source commit is deliberately not built, and Cosmos contexts are outside the mechanism. Adopted by six ITransactional commands (four ADC, two Store) plus direct callers in the session store and both auth services.
108 Cross-replica mutual exclusion via IDistributedLock One non-reentrant, TTL-bounded, best-effort lock contract (TryAcquireAsync(key, ttl, wait), owner-scoped idempotent release, never a consensus protocol and never the only guard on a persistence-enforceable invariant), backed by RedisDistributedLock (SET NX PX plus a Lua compare-and-delete on a lock: namespace) when a multiplexer resolves and by a warn-once process-local InProcessDistributedLock otherwise, both TryAddSingleton in AddCaching. Two consumers: the idempotency filter's execute-then-store window (30s TTL, 5s wait, a duplicate that cannot acquire gets 409; a faulting backend runs unguarded and is counted) and ADC's session-scoring claim. The choose-between rule: the outbox (ADR-003) and scheduler (ADR-074) keep their database claim-lease because they guard correctness; the lock only collapses duplicate work.
109 Feature-by-folder layout as an enforced convention The aggregate names the first folder level in Domain, Application and Shared; UI, API and Infrastructure put a technical root first and the aggregate beneath it, with the same plural name in every project of a module; no folder holds more than twelve direct code files (FolderWidthTestsBase, subclassed in all four repos, a .razor + .razor.cs pair counting once, generated files and Migrations//Platforms/ trees skipped); namespaces follow folders (IDE0130 under TreatWarningsAsErrors), so a folder move is a public-API rename on a lockstep-released package family shipped as a breaking release with an UPGRADING.md map; and a module sub-folder is never named Domain, Application, Infrastructure, API or UI because ModuleNameConventions derives the schema, container and data-source name from those namespace segments (review-enforced). Moves run through Tools/Scripts/move-namespace.ps1. Recorded cost: the v1.183.0-v1.185.0 sweep is why most ADR path citations drifted in the 2026-09-03 audit.
110 Rubric v2: category realignment at 34 The 34-category evaluation rubric is versioned, and version 2 replaces two overlap-heavy categories in place rather than growing the list: §10 Cross-Cutting Concerns (four of its five criteria already scored in §5, §6, §9, §12 and §29) becomes Messaging & Integration Architecture at weight 3 (broker topology by ADR, delivery semantics per consumer, dead-letter and poison handling, retention and replay, contract evolution with consumer-driven tests, sagas with compensation, gateway/BFF discipline), and §16 Maintainability & Evolvability (its unique coupling and tech-debt criteria folded into §34, upgrades into §32, onboarding into §33) becomes AI-Native Application Architecture, N/A until a product feature calls a model. Eleven categories gain criteria the topic map exposed as blind spots: sagas, ACL and Strangler Fig, analytical/OLTP separation, expand-contract, polyglot by ADR, contract tests, threat model and service-to-service auth, progressive delivery, a single resilience mechanism, agentic-tooling guardrails, and a stated tenancy model. Category numbers are stable, so every scorecard row, backlog item and ADR citation survives; the cost is a denominator change (Σweight 81→80 for Common, 80→79 for ADC and Store) recorded in each scorecard header, §10 carried at prior scores until its first re-score against the new criteria, and prose in onboarding, articles and the wiki that names the two old titles until the governance commands refresh it.
111 AI session scoring governance ADC's one product feature that calls a language model, governed as a production dependency. The call sits behind IAiScoringService (declared in Application, carrying ModelId and PromptVersion), model claude-haiku-4-5, and both versions are persisted on every SessionAiScore row (PromptVersion nvarchar(32), expand-only migration defaulting pre-column rows to legacy) so a score is attributable to the exact reviewer brief behind it. A SHA-256 hash of the rendered prompt is pinned per version in Golden/prompt-versions.json, so a prompt edit without a bump fails a test instead of silently re-basing every score on the dashboard; the five-step change protocol (bump, record hash, run the live judge, review drift case by case, accept a mixed-version dashboard) is in the suite's README. The ai-eval-gate job is in deploy.needs: golden replay plus prompt contract on every code deploy (seven recorded cases, no key, --minimum-expected-tests 1), and the paid live judge only when the diff touches the scoring paths. Input guardrails are a delimited <session_proposal> envelope with angle brackets escaped, a named anti-injection paragraph wiring an override attempt to the existing 1.0 penalty, and email/phone redaction of submitted free text (names deliberately kept, they are the credibility evidence). Output is schema-constrained structured JSON with refusal, empty and partial responses all failing rather than defaulting, and the weighted overall computed in C#, never by the model. Cost is scoring.tokens.input/scoring.tokens.output tagged by model and prompt version on meter MMCA.ADC.Conference.Scoring, with a 30-day rolling token-ceiling alert; the real spend control is that the only trigger is a permission-gated organizer click and nothing starts unrequested paid work. Costs: recorded-response replay cannot see model drift, the live judge needs the key in CI and reports green when it skips, and score semantics change on every prompt bump.

Writing a new ADR

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 chose, in enough detail to implement), Rationale (why this over the alternatives), Trade-offs (what it costs). Number sequentially (NNN-kebab-title.md) and add a row above. Keep ADRs short and decision-focused; deep mechanics belong in the workspace-level Docs/Architecture/ArchitecturalAnalysis.md (outside this repo) or the per-project CLAUDE.md.