↑↓ 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.

132 accepted ADRs, 001-132, numbered contiguously with one row per ADR in the table below. This file owns the ADR count and range (MMCA.Common/FACTS.md): read them here rather than recounting, and when you add an ADR, update this line with its row. tools/adr-index.ps1 in this repo re-derives both from the files and fails if this line, the table, or a link drifts from them; CI runs it on every push and pull request.

# 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. Revised 2026-10-06: four engines including PostgreSQL; Include support is classified by source identity on relational engines, with every Cosmos DB navigation unsupported.
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. Revised 2026-09-07: shared broker, cache and backplane names are prefixed per application by default (Application:Namespace), so a cutover either pins the old prefixes or drains the old queues. Extended by ADR-114 (2026-09-09): the claim-lease, jittered-backoff, dead-letter and retention idiom is borrowed by a second per-source table, InternalCommands, which carries instructions rather than events; the outbox itself is unchanged, its poll predicate stays single-workload, and the two processors share an idiom rather than an implementation. Revised 2026-09-11: an outbox row carries the ambient context it was written under (TenantId, UserId, UserRoles, CorrelationId, captured through the shared AmbientOrigin helper the internal-command queue uses) and OutboxProcessor restores it onto the cycle's scope before each dispatch, so a delivered handler is no longer an anonymous, tenant-less, uncorrelated system call; expand-only, one nullable-column migration per relational outbox source. Revised 2026-10-01: Domain event handlers do not flush a unit of work themselves, enforced by a transitive fitness rule. Revised 2026-10-07: each row's lease is renewed before dispatch and its outcome written as soon as it finishes by a lock-token-guarded update (no batch save), and async-path local domain-event rows are inserted already leased, so the lease, not the processing delay, bounds duplicate dispatch.
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. Revised 2026-10-07: gateway-routed discovery is scoped to the local Aspire wiring (production ACA validators in ADC and Store use the direct internal-ingress Identity authority), and the resilience claim now says the Polly pipeline covers the warmup prefetch but not the JwtBearer middleware's own discovery fetch.
005 Soft-delete vs. erasure Soft-delete stays for lifecycle; IAnonymizable + outbox purge for GDPR/CCPA erasure. Revised 2026-09-07: a pseudonymous key kept through anonymization (customer id, order id) must not reach an anonymous surface; Store's public review list is projected through PublicProductReviewDTO, which declares neither. Revised 2026-10-06: Store's public review contract carries no reviewer name either, and the interceptor and controller anchors were refreshed.
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. Revised 2026-09-09: PostgreSQL is a fourth engine (ADR-113), so the per-engine context set is SQL Server / Cosmos / SQLite / PostgreSQL; the one-context-per-engine rule is exactly what kept the addition to a configuration base, a sealed context, a design-time factory and the switch arms IDE0072 names, with nothing in Domain, Application or a consumer handler touched. Revised 2026-10-06: the AtlDevCon bacpac was deleted on 2026-10-03, so no pre-cutover rollback copy exists; the four live ADC_* databases rely on their own PITR plus LTR.
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). Revised 2026-10-06: the [ServiceContract] ratchet is now triggered (ADC marks six interfaces and Store four), a second fitness rule keeps their implementations non-public, and the h2c, JWKS and disabled-stub bullets are narrowed to current wiring.
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), both Gateways layer 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. Revised 2026-09-25: ADC's Gateway alone evaluates each route's declared anonymous policy through an authorization middleware pair (no authentication scheme, so it authenticates no one) and raises the body cap for session-asset uploads, while Store's routes declare anonymous with no authorization middleware by design; anchors refreshed. Revised 2026-10-01: Extracted hosts discover only their own module and reach peers over gRPC clients, and JWKS federation bypasses the Gateway in production. Revised 2026-10-07: the cross-service tier's cadence is corrected to weeknights only (a scheduled night is skipped when main has not moved since the last successful run), the single arm-deploy step is scoped to main.bicep, and anchors are refreshed after the v1.233.0 release.
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). Revised 2026-09-11: the standard handler's runtime behaviour becomes observable through the Polly meter the Aspire defaults now subscribe (resilience.polly.strategy.events always exported, the two duration histograms behind Telemetry:EnablePollyDurationMetrics), and Smtp:TimeoutSeconds (default 30, [Range(1, 600)], validated at startup) bounds the one framework outbound client that is not an HttpClient. Revised 2026-10-06: the DR-doc acceptance is described as written rather than signed off, the example consumer and the drill-table mitigation now match MMCA.Store and the dr-freshness gates, and the Stripe retry predicate also treats a client timeout as transient.
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 hosts that must also accept HTTP/1.1 (ADC's Notification service, for its SignalR/WebSocket hub, and Store's Sales service, for REST and the Stripe webhook over HTTP/1.1). 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). Revised 2026-09-07: the gRPC rate-limit bypass and the HTTPS-redirect skip key on endpoint metadata and the negotiated protocol (IsCleartextHttp2), not on a caller-set Content-Type. Revised 2026-10-06: Store Sales dev ports corrected to 5266/5265/5267 with their Hyper-V/WinNAT rationale, and anchors re-verified. Revised 2026-10-07: Context now lists both apps' current gRPC edges (including Store's authorized Identity -> Sales and Identity -> Catalog export edges), Profile B's HTTPS/ALPN gRPC and single-argument JWKS bullets are marked historical, and ADC anchors refreshed after the v1.233.0 release.
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. Revised 2026-10-06: ErrorType.TooManyRequests (HTTP 429, gRPC ResourceExhausted, severity rank 45) joins the category set, the catch-all exception handler also answers BadHttpRequestException with the status it carries (413 for an oversize body), and the UI executor counts Polly's ExecutionRejectedException as a transport fault. Revised 2026-10-07: a client-synthesized failure is localized by its code through the caller's localizer and, when the caller passes one, then the framework's own SharedResource pair before falling back to English, and the error-catalog rule's own summary now states that it does not read Error.TooManyRequests; anchors refreshed after the v1.232.1 release.
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(). Revised 2026-09-07: the caching decorator scopes its key by caller for a query marked IUserScopedRequest, with ISharedQueryCache as the opt-out. Revised 2026-09-11: both logging decorators log a thrown exception at Warning and without the exception object, leaving the single Error row with the full stack to the boundary that handles it (GlobalExceptionHandler, DbUpdateExceptionHandler, or InternalCommandProcessor for a deferred command); the duration histograms and their outcome=exception tag are unchanged. Revised 2026-09-25: ADC's decorator-order fitness test composes through AddMmcaApplicationPipeline and runs VerifyDecoratorPipeline(), as Store's does, so both production consumers pin the pipeline's order and its coverage. Revised 2026-10-01: The record states where each kind of validation lives: UI form models, the Validating decorator with its coverage fitness gate, and domain factories. Revised 2026-10-06: the Decision states the shipped seven-decorator command chain and six-decorator query chain instead of the pre-2026-08-18 order. Revised 2026-10-19: Helpdesk's web host composes through AddMmcaApplicationPipeline too, so all eight hosts take the sealed path.
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. Revised 2026-09-09: ADR-115 adds a rule family (StronglyTypedIdsAreReadonlyRecordStructs, exposed as StronglyTypedIdTestsBase and vacuously satisfied in every repo today), and the FACTS.md figures now read 127 test methods across 48 abstract *TestsBase classes, of which MMCA.Common's own build executes 232. Revised 2026-10-01: RS0026 / RS0027 move from off to error, with each already-shipped overload pair suppressed at its own declaration. Revised 2026-10-06: the ADR-115 rule runs in three of the four repos rather than every repo, the idempotency gate is exercised by MMCA.Common's own controllers, and the baseline and proto fixture figures are re-counted. Revised 2026-10-07: MMCA.Common runs a second architecture map, FrameworkModuleArchitectureMap, over its own aggregates, the shipped baselines are re-counted against v1.233.0, and the anchors that moved are refreshed.
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. Revised 2026-10-06: the ordered replacement list in Transport exit options is ceded to ADR-118, which accepted Wolverine first and a raw-SDK adapter second and rejects a commercial v9, so the three 2026-08-28 candidates remain only as the record of the first sketch. Revised 2026-10-07: the emulator proving ground is restated as a five-day recency gate (a red nightly blocks a deploy only once the last run green on both broker jobs is stale), and the Store props and both cross-service-tests.yml anchors are rebased.
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. Revised 2026-10-01: Keys are bound to the request body with a 422 on reuse, replays carry Location and ETag, and lock or cache faults fail open. Revised 2026-10-07: the shared [Idempotent] inventory is corrected (email confirmation, entity create and update, notifications and legal acceptance carry it too, not only the password-reset pair) and the shared admin lock/unlock POSTs are recorded as declared by each consumer override rather than by the base.
018 Polyglot persistence (per-engine sources) Four storage engines (SQL Server / PostgreSQL / 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. Revised 2026-09-09: a fourth engine, PostgreSQL (ADR-113), mirroring the SQL Server footprint one for one and differing only where the server forces it: DataSource.PostgreSQL appended rather than renumbered, the same switch arm as SQL Server in the engine-aware configuration base (no snake_case convention imposed, so moving an entity between the two is a base-class change), engine-branched soft-delete and partial-index predicates built through one SoftDeleteFilterSql / QuoteColumn pair, UTC timestamps normalized by a model-scoped converter instead of the process-wide EnableLegacyTimestampBehavior switch, migrations only when a migrations assembly is named (the SQLite rule), and a Testcontainers tier outside the solution that proves it against a real server. No production entity routes to it yet. Revised 2026-10-06: per-engine behavior now lives in an internal engine registry (DataSourceEngines), and Cosmos's missing outbox follows from its non-relational capability rather than a SupportsOutbox member.
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 credential endpoints get the per-IP auth-ip cap (default 30/min): AuthControllerBase applies it to login/register and PasswordResetAuthControllerBase to forgot/reset-password by default, and both apps extend it to their send-email-confirmation/confirm-email actions, 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. Revised 2026-09-07: the gRPC exemption keys on endpoint metadata and real h2c rather than a caller-set Content-Type; anonymous /hubs traffic is metered per IP (RateLimiting:HubPathPrefixes, AnonymousHubPermitLimit); ADC's auth-tight gateway policy covers only login and register; and GatewayRateLimiting:TrustedCallerSecret exempts a proven internal caller. Revised 2026-09-10: both gateways now scope auth-tight to the two credential routes with /Auth/{**catch-all} on the edge limiter alone (Store split its catch-all and pinned the shape in a test), and the client half of the trusted-caller exemption ships as AddTrustedCallerHeader in MMCA.Common.UI.Web, adopted by both UI hosts and reading the same GatewayRateLimiting section the gateway binds. Revised 2026-09-19: the Decision body is reconciled with the 09-07 revision (endpoint-metadata gRPC exemption, metered anonymous hub partition, subject-claim partition key) and with the widened auth-ip surface. Revised 2026-10-06: every ADC service binds the RateLimiting section, and the email-confirmation auth-ip decoration is recorded as owned by the framework base rather than by each app. Revised 2026-10-07: the distributed limiter's Redis partition key is qualified by the application namespace (MMCA.Common v1.232.0), so by default two MMCA applications sharing one Redis no longer share counters.
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). Revised 2026-09-07: an endpoint that declares no authorization requires an authenticated caller (AddAuthorizationPolicies fallback policy, path-prefix exemptions, options.Enabled = false to opt out), anonymity has to be declared explicitly, and AnonymousEndpointTestsBase.RequireExplicitAuthorizationDecision enumerates undecorated endpoints. Revised 2026-09-09: ADR-116 layers OPTIONAL stored grants over the compiled registry: LayeredPermissionRegistry decorates whatever IPermissionRegistry the host registered and unions a flat (Role, Permission) table into it, there is no deny row (a data edit can only widen a role, so the effective set never depends on evaluation order), and because HasPermission is synchronous the grants are a per-role IMemoryCache snapshot rebuilt on Authentication:PermissionGrants:CacheSeconds and on explicit invalidation, so a cold cache grants nothing and the compiled layer answers from the first request. Revised 2026-09-25: ADC's IdentityPermissions.All lists only the capability ADC defines and its grant map names the framework's ManageUsers and ManageRoles beside it, the declaration shape Store uses; the effective grants are unchanged. Revised 2026-10-06: ADC's Conference module defines twelve permissions (SpeakersLink added, Organizer-only), and the explicit-authorization-decision fitness gate is on by default with all four repos held to it.
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. Revised 2026-09-07: an unset MessageBus:EndpointPrefix now resolves to the application namespace, so a cutover pins the pre-upgrade prefix or drains the old queues. Revised 2026-09-11: a broker-delivered event restores the publisher's tenant, principal and correlation id from the MMCA-* headers BEFORE the inbox is touched, because under database-per-tenant the inbox store routes through the same scope; an absent header leaves the consumer's defaults untouched, so an older publisher keeps working. Revised 2026-10-06: the inbox row commits atomically with a handler's mutations only for a handler that saves on the consumer's own scope; the framework's ScopedIntegrationEventHandlerBase handlers save on a scope of their own, so for them the row is written by CompleteAsync after they succeed and the crash window stays open.
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. Revised 2026-10-06: both apps run the same-origin API proxy, so the server writes the session cookies, the browser receives only a claims-only token, and SameSite is a configured value (Lax in both apps).
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. Revised 2026-09-07: the UI hosts apply forwarded headers and emit their own HSTS (not gateway-only), and SecurityHeadersSettings.CredentialPathPrefixes forces Referrer-Policy: no-referrer plus Cache-Control: no-store on the reset-password and OAuth-completion paths. Revised 2026-10-06: credential paths also answer Pragma: no-cache, re-apply their cache headers at response start over a weaker downstream value, and have their prefixes validated at startup.
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). Revised 2026-09-07: hub connections are capped per user (PushNotifications:MaxConnectionsPerUser, default 20) and the SignalR backplane channel prefix defaults to the application namespace. Revised 2026-10-01: The opt-in dedup key is scoped to the sender. Revised 2026-10-06: the dedup race is recorded as propagate-and-roll-back under the ITransactional send, not catch-and-requery. Revised 2026-10-07: the hub client connects WebSockets-only with negotiation skipped, in both connection modes, so a multi-replica hub needs no session affinity and there is no SSE or long-polling fallback.
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 eleven of twelve 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. Revised 2026-09-07: Tier-1 keys carry the caller for an IUserScopedRequest query (ISharedQueryCache opts out), the public output-cache key varies by resolved tenant and reads roles through ClaimsPrincipalExtensions.HasRole, and Cache:KeyPrefix defaults to the application namespace. Revised 2026-09-25: all seven ADC and Store services call AddRedisCaching (and both adopters AddRedisOutputCaching) unconditionally, relying on the wrappers' own blank-connection-string no-op; only AddCommonHybridCache stays behind the Redis conditional. Revised 2026-10-06: the client tier invalidates after any write that may have landed, not only after a successful one, the default TTL is host-configurable through Cache:DefaultDuration, and ADC Conference's eleven-of-twelve count is scoped to its public-read policies beside the built-in ConferenceCache policy. Revised 2026-10-07: with Redis configured, the 30-second local expiration is also the cross-replica backstop, because a HybridCacheService removal does not reach other replicas' L1 copies; anchors refreshed after the v1.233.0 release.
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. Revised 2026-10-06: displayed times follow the viewer's clock except a conference schedule, which follows the event's own time zone (Decision 11), and client-synthesized HTTP failures localize by error code. Revised 2026-10-07: when the caller supplies a localizer, a code-keyed client failure resolves from the framework's own SharedResource pair before the English fallback, and the stale "only coverage collection is chromium-only" note on the pseudo-locale gate is removed.
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. Revised 2026-10-06: the first-paint flash is scoped to web heads and the MAUI first-frame mode source is recorded.
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. Revised 2026-09-07: the account-state gate runs after password verification (so a failed attempt against a gated account counts), an account with empty hash and salt is unauthenticating, and an unknown address pays the same key-derivation cost. Revised 2026-10-01: Change-password is a third framework call site, with a principal-keyed counter; every ADC and Store service host calls AddCommonHybridCacheWhenRedisConfigured, so with Redis configured the counters run through HybridCacheService.IncrementAsync. Revised 2026-10-07: a login or change-password lockout answers 429 Auth.TooManyAttempts (an Error.TooManyRequests, since Common 1.219.0) rather than a uniform 401, while the registration throttle still answers 401; see Revision below.
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. Revised 2026-10-06: seeding runs on the default scope only, after a tenant-database pass, and the startup path consults the environment for the build-time OpenAPI skip. Revised 2026-10-07: concurrent replicas of one service are a supported case (EF's migrations lock plus a five-minute initialization command timeout), so minReplicas: 1 is not a single-applier guarantee.
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. Revised 2026-09-11: the flag-debt trade-off is closed by [FeatureFlag] (lifetime, RemoveBy, owner), a runtime FeatureFlagRegistry, and two fitness rules behind FeatureFlagLifecycleTestsBase that fail the build for an undeclared flag or a temporary one past its removal date; adoption is per repo, and the framework's own flags are annotated Permanent. Revised 2026-10-06: the CQRS disabled failure no longer names the flag; its message is the generic wording the edge handler uses, and clients branch on the Feature.Disabled code.
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, by MMCA.ADC on both axes: Engagement's Bookmarks for the action filter and Conference's two question-answer controllers for the row-level specification. 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. Revised 2026-10-01: The fail-closed owner gate and the per-record ownership check are now framework code in OwnershipHelper. Revised 2026-10-06: the CSV export's fail-closed AllowUnscopedExport opt-in, which lets only the bypass role export an unscoped table, is now recorded in Adoption.
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. Revised 2026-09-07: sort, filter and lookup-name keys resolve against the response contract (QueryFieldContract, LookupNameContract), navigation paths are capped at MaxNavigationDepth 3, the lookup read takes the 1000-row cap, and the selector cache is bounded at 512 canonical entries.
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. Revised 2026-09-07: IWriteRepository.TouchConcurrencyToken makes a conditional write stamp the aggregate root even when only children changed, so a stale If-Match answers 412; IConcurrencyConflictDetector lets a handler recognise a lost claim race without catching a provider exception. Revised 2026-10-06: the RowVersion mapping and the PostgreSQL/SQLite re-stamp are selected by the engine's declared RowVersionStrategy capability rather than by provider, and stale citations were refreshed. Revised 2026-10-07: MMCA.Helpdesk also subclasses ConcurrencyConventionTestsBase (three consumers), MMCA.Common's own subclass is recorded as vacuous (its map declares no module layer), and stale anchors were refreshed against current source.
036 External OAuth login (Google/GitHub/Apple) 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). Revised 2026-09-07: an external account's empty hash and salt are explicitly non-authenticating on login and change-password, OAuth completion requires client-side flow state (OAuthFlowStateStore), ADC's OAuth link-by-email requires both a provider-verified address and a local account with no password (User.HasLocalPassword, Auth.ExternalLinkRequiresLocalSignIn), and the speaker auto-link takes the same verified-email requirement. Apple joined Google and GitHub as a third config-gated provider 2026-09-19 (no static client secret: TeamId/KeyId/PrivateKeyPem, AppleEnabled UI flag); the by-email link now runs three guards, and token issuance goes through the shared refresh-session workflow.
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. Revised 2026-09-07: actions are SHA-pinned, global tools version-pinned, restores run --locked-mode, the vulnerability gate fails closed on tool error, the release runs in a protected release environment behind a merged-main assertion, and Store adds a docker Dependabot ecosystem. Revised 2026-10-06: the SQLitePCLRaw pin is recorded at its current version (3.0.5) and both projects that reference it, and wasm-payload-budget joins the first-party actions consumers reference by branch. Revised 2026-10-07: anchors refreshed after the v1.233.0 release, and the 2026-09-07 record of two dotnet-coverage installs is updated: the second install has since been removed, leaving the single pinned install in CI build-and-test.
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. Revised 2026-09-07: channel joins can be gated by an app-supplied IChannelJoinAuthorizer, connections are capped per user, the backplane channel prefix is per application, and ADC returns live poll results only for Open or Closed polls on published sessions.
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 eleven of ADC's twelve 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. Revised 2026-09-25: Store's ProductsCache runs a 60-second TTL because a variant's effective price moves on the clock with no write to evict on; both hosts register the Redis output-cache store unconditionally through AddRedisOutputCaching. Revised 2026-10-06: ADC bookmark eviction is raised by a paced hosted processor, the admin-freshness driver is broker-round-trip latency rather than unevicted writes, and ADC Conference scales to four replicas in conference mode (. Revised 2026-10-07: the Decision now records the policy's tenant vary rule (the key varies by the resolved tenant) and its wide HasRole bypass check, and anchors are refreshed after the v1.233.0 release (see the Revision (2026-10-07)).
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 both Blazor UI hosts, and by neither Gateway, guarded by one MMCA.Common test. Revised 2026-09-07: the Log Analytics daily cap carries a cap-reached alert in both apps, SQL auditing and resource diagnostic settings are deployed in ADC and Store, Store alerts on sustained 401s that the SLO rules exclude, and stored reviewer names were scrubbed of email addresses. Amended 2026-09-11: a ninth subscribed meter, Polly, makes retries, timeouts and circuit openings visible (resilience.polly.strategy.events always exported; the two duration histograms dropped by a View unless Telemetry:EnablePollyDurationMetrics is true), and the CQRS logging decorators drop their exception line to Warning without the exception object so the boundary owns the one Error with the stack. Revised 2026-09-25: MMCA.ADC.UI.Web calls AddCommonSerilog, so nine hosts share the helper (the seven ADC/Store service hosts plus both Blazor UI hosts) and the two Gateways are the only hosts without Serilog. Revised 2026-10-01: ASP.NET Core metrics are now behind a third cost knob, Telemetry:DisableAspNetCoreMetrics, which both production deployments turn on. Revised 2026-10-06: the correlation middleware also discards a non-printable-ASCII id, Store's auth alert counts 401 and 429, the bootstrap logger factory lives until the host exits, and GlobalExceptionHandler writes its Error row only on its 500 path (cross-tenant and bad-request rejections log Warning). Revised 2026-10-07: Context and the rejected-alternative now separate unsampled tracing (the largest line item) from sampled traces (metrics measured larger in every recorded window), an outbox message with no stored trace context gets no OutboxProcess span, and the outbox, ADC bicep, ADC Conference Program.cs and GlobalExceptionHandler anchors are rebased after the v1.232.1 and v1.233.0 releases.
042 Device capability abstraction (MAUI Blazor Hybrid) Per-capability contracts in MMCA.Common.UI (Services/Capabilities, 18 at introduction, 24 today after ADR-044/045, geocoding, local-cache and app-lifecycle 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). Revised 2026-09-11: AddMauiDeviceCapabilities covers 20 of the 24 contracts; IPushDeviceTokenProvider comes from the platform-conditional AddMauiPushDeviceTokenProvider, IBarcodeScannerService stays behind UseCommonBarcodeScanner, and IDeepLinkDispatcher and IAppLifecycleNotifier need no native override (the notifier is fed from the native window via AttachMmcaAppLifecycle). Revised 2026-10-06: the concern sub-folder list gains Navigation/ (nine, not eight), and the lifecycle no-op is cited at the extension rather than the ADC call site.
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. Revised 2026-09-07: DeepLinkDispatcher.Publish accepts only app-relative routes (IsAppRelativeRoute), a shape-based guard that also covers an explicit intent bypassing the Android intent filter. Revised 2026-10-06: the native completion now round-trips a per-attempt state value that the shared completion page checks before exchanging the code, and the Android App Links filter claims only the deep-linkable path prefixes. Revised 2026-10-07: anchors refreshed after the v1.233.0 release, and the hostname trade-off no longer calls the MainActivity cutover comment a two-spot note, since it now names the same three binary spots.
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. Revised 2026-10-01: UI.Maui ships credentialed FCM/APNs token providers and token-rotation re-registration. Revised 2026-10-06: the device DELETE route is /Notifications/Devices/{installationId}.
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. Revised 2026-09-07: ImageSharpImageProcessor checks MaxDecodedPixels (50 MP) and MaxDecodedDimension (20000) from the image header before allocating a frame buffer, beside the 2 MB compressed cap. Revised 2026-10-01: Avatar blob deletion is a durable internal command rather than an inline storage call.
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. Revised 2026-09-25: both MMCA.ADC and MMCA.Store commit a build-time openapi/<host>.json per service host (the MmcaGenerateOpenApiDocument target in each Directory.Build.props) and fail build-and-test on an uncommitted difference. Revised 2026-10-06: the registration also adds ApiVersionReportingResultFilter as a global MVC filter so output-cached responses keep the version headers; re-anchored the citations.
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. Revised 2026-10-06: the deleted marker now lives 15 minutes while a "not deleted" answer still caches for 30 seconds, and the marker is honored on every host, including one with no validator. Revised 2026-10-07: a "not deleted" answer is no longer cached at all and the marker is read from the shared store, so an Identity host runs the validator query on every authenticated request and the 30-second window is gone.
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; the framework now ships one as an opt-in capability (ADR-115) with zero adoption in any Source tree. Revisited by ADR-085 (2026-08-18): the deferral is re-evaluated, priced (48 aliases as of the 2026-09-19 recount, 46 of them int; 3,767 lines across 1,192 files to migrate) and upheld, now with three named revisit triggers instead of an open-ended "not now". Revisited again by ADR-115 (2026-09-09): the wrapper struct becomes a shipped opt-in capability (a two-member interface, JSON as the bare primitive, an EF pre-convention type mapping on the one base context, MVC TypeConverter route and query binding, OpenAPI, filtering and Mapperly helpers, all behind one AddStronglyTypedIds call), while the aliases stay the default, nothing migrates and adoption is zero by design. The trigger was the 2026-09-09 framework gap analysis, not any of ADR-085's three named triggers.
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 2026-09-11 at 1,059 gated await sites of 1,169. Revised 2026-10-06: the UI exemption is restated as leaving the rule unenforced in mixed UI-package code (those packages now call ConfigureAwait(false) 154 times), and the drift script is no longer credited with guarding the repo-delta section. Revised 2026-10-07: the consumer-scale figure is re-measured after ADC growth (ADC 1,544 occurrences, 2,359 combined) and the .editorconfig gate and exemption anchors are refreshed.
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. Revised 2026-09-25: both apps' subclasses take IOptions<RefreshSessionSettings> directly; account lock and role change revoke live refresh sessions from each app's user-administration service; Store alone answers a refresh for a vanished user with 404 (ADC keeps the base 401). Revised 2026-10-07: the body's "Today" parentheticals now record the rotation grace window, under which a token rotated moments ago is answered 409 with nothing revoked instead of revoking the user's sessions, and the AuthSessionIssuer anchors are refreshed.
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. Revised 2026-10-06: the browser refresher reports a three-way TokenAcquisition, the browser storage services hold a 15-second anonymous grace, the MAUI single-flight is process-wide, and the bearer handler leaves an existing Authorization header alone. Revised 2026-10-07: on the same-origin API proxy hosts (both consumers) the Blazor Server circuit refreshes and seeds cookies through a protected handoff, the browser receives only a claims-only token, and the proxy strips and re-stamps the bearer; the MAUI refresher treats a 409 as transient and keeps the stored pair.
052 Background job execution (bounded queue + hosted drain, superseded) 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. Superseded by ADR-121. The ephemeral half (bounded DropOldest channel plus hosted drain) continues there; the expensive half (Wait mode, in-queue dedup, 409 refusal, SessionScoringQueue) has no implementation and that work runs as ADR-114 internal commands.
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 covers the nuget.org steps only (release.yml:108, :115, :209, :216), so a fork skips the trusted-publishing exchange; the GitHub Packages pushes are unguarded and hardcoded to the ivanball namespace (release.yml:96, :202), so a fork's run reaches that push and fails there. 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. Revised 2026-10-07: anchors re-pinned to the current release.yml and ci.yml, the Decision names the SHA-pinned NuGet/login action, and the Trade-offs record that the two publishing jobs run independently, so a release can land half-published.
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, in two passes: it reconciles stuck PaymentInitiated orders against Stripe's authoritative session status, driving the same guarded transitions a lost webhook would have and losing races to the webhook by design, and it expires stranded unpaid orders that never reached Stripe to Cancelled so their stock is released. Since 2026-09-11 that expiry half is the backstop rather than the primary path: checkout arms a per-order Sales.ExpireUnpaidOrder internal command (ADR-114) inside the checkout transaction for now + UnpaidOrderExpiry:Minutes, a failed payment re-arms it, and its handler re-derives the due instant from the order, so an unpaid order is normally cancelled as its window closes rather than up to one poll interval later. 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. Revised 2026-09-25: OrderCancelledSagaHandler returns without saving (so no redelivery) when an inventory invariant rejects an increase; the checkout deadline is armed through a CheckOutDeadlineScheduler collaborator inside the same transaction; ADC's one compensating step (the session-asset orphan-blob delete, ADR-123) is noted beside the Sales-only saga adoption. Revised 2026-10-01: The restoration marker and the stock increase commit in one transaction, marker first, with the stock moved by an atomic relative update; the reconciliation pass is gated by PaymentProof.
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. Revised 2026-10-06: the And/Or/Not combinators now refuse a shaped QuerySpecification with an ArgumentException, so composition is criteria-only by enforcement; ADC's narrowing is restated as forty-four sites across twenty-seven files, one of them an API controller.
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. Revised 2026-10-06: corrected the rationale for skipping the prerender fetch on detail pages, recorded the framework's UserAdminList as a further inheritor of the list-page base, and re-anchored the citations to current source.
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, security headers on all four browser-facing hosts (both Gateways and, since 2026-09-10, both UI web hosts), and graceful shutdown on the Gateways only. 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. Revised 2026-09-25: the live-document OpenAPI guard is reconciled with the committed documents both consumers gate at build time (a separate check, not an input to the suite); MMCA.Common.Testing.UI ships ConfirmEmailPageTestsBase, RoleAdminListPageTestsBase and RoleAdminEditPageTestsBase, run by ADC and Store through one-line subclasses. Revised 2026-10-06: the consumers' lockstep MMCA.Common.Shared pin is now 1.232.0 and this Status block lists every Revision section. Revised 2026-10-07: the consumers' lockstep MMCA.Common.Shared pin is now 1.233.0 and the ADC pin anchor moved to Directory.Packages.props:101; see the Revision (2026-10-07) below.
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 a monthly and on-demand k6 load test, not a PR gate, whose recency gates every production deploy), and MMCA.Helpdesk has neither. Revised 2026-09-25: both consumers gate every production deploy on the recency of their k6 run (load-freshness in deploy.needs, 35-day window). Revised 2026-10-07: the consumer load-freshness gate is a step of a single freshness job, which deploy.needs lists, and the shared action, which the consumers pin at @main, filters runs client-side; anchors re-verified against current source.
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; 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-11: nineteen ADC and fourteen Store vault secrets; the ADR-088 synthetic-traffic and trusted-caller secrets reach the Gateways and UI apps through secretRef, so no app declares secrets: [] any more. Revised 2026-09-07: each service authenticates to Service Bus with its own SAS rule rather than one namespace-wide rule (ADC and Store), and ADC parameterized the Data Protection key-ring encryption posture as three independently reversible steps. Revised 2026-10-06: ADC's storage Shared Key parameter is no longer documented as required by any consumer, and every citation is re-anchored. Revised 2026-10-07: Key Vault purge protection is recorded as prescribed by both out-of-band vault bootstrap runbooks rather than open, the reference sample's vault write is pinned to its own resource, and every moved ADC citation (plus four Store and Common ones) is re-anchored.
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 subclass that raises MinimumAlertSpecs to its own spec count (5 and 4); 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 they are ungated. MMCA.Helpdesk has no infra/ template at all. Revised 2026-09-11: SLO rules evaluate every fifteen minutes in both templates; twelve alerts now sit outside the parsed window, six per consumer (ADC adds log-ingestion-cap and AI-scoring-ceiling rules, Store adds auth-failure-spike, forbidden-burst and log-ingestion-quota), and nine carry #### triage: Store six of six, ADC three of six. Revised 2026-09-25: ADC's ai-scoring-token-ceiling is a fifth gated spec (with per-spec cadence and enabled overrides), so the ungated space is eleven alerts (ADC five, Store six), and every one of the eleven carries #### triage. Revised 2026-10-06: the framework base now ships a fourth, opt-in fact (RequireWorkbook, enabled only by MMCA.Common's own deployment-sample subclass), the latency predicate and the Common adoption boundary are stated as the code has them, and every ADC citation is re-anchored. Revised 2026-10-07: ADC's scheduledQueryAlertSpecs gained signalr-backplane-errors, so the ungated space is twelve alerts (six per consumer) with triage for eleven, ADC's runbook having no section for the new rule; ADC anchors and both Governance anchors are re-pointed.
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). Revised 2026-10-06: the deploy-gate narrative now records the cross-browser-freshness gate that enforces firefox and webkit recency on both deploys, and ADC's ProfileManagementTests base is named precisely (AdcE2ETestBase); no decision changed. Revised 2026-10-07: the CI workflow prune of that day folded cross-browser-freshness into a single freshness job that the deploy needs list names, so the record now cites it as a step of that job, and the CI and deploy anchors are refreshed after that prune; no decision changed.
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 / cross-browser-freshness sit in deploy.needs and query the Actions API for the newest successful DR drill (8 days), k6 load run (35 days), Testcontainers broker round-trip (5 days) and per-engine firefox and webkit E2E leg (10 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 four 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. Revised 2026-10-01: The four gate bodies now run as one shared composite action owned by MMCA.Common, so the per-repo copies and their message differences are gone. Revised 2026-10-06: the job-reading gates now page an unfiltered run listing and filter completed runs client-side (MMCA.Common v1.220.0), and every citation is re-anchored. Revised 2026-10-07: the four gates now run as the four steps of one freshness job that skips a docs-only push, backend-test-gate is retired in both repos, and the success-only lookup filters client-side; the Rationale's API-cost sentence now states the bounded read count and the per-repo job timeout, and the Decision notes that both CONTRIBUTING files still name the gates as jobs.
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. Revised 2026-10-06: the seed measurements were re-taken at MMCA.Helpdesk f6ef4b0, and Directory.Build.targets is now a 7-line import of the local-source swap MMCA.Common ships rather than the swap itself.
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. Revised 2026-09-07: each service sources its broker connection string from its own Service Bus SAS rule in both consumers, so rotation and blast radius are per service. Revised 2026-10-01: Store's AppHost now carries the same STORE_BROKER=servicebus emulator opt-in as ADC, so both consumers can run the production transport locally. Revised 2026-10-06: the broker selector both AppHosts use is now the framework's BrokerSelection rather than a repo-local helper, ADC's per-service broker secret is recorded alongside Store's, and anchors are re-verified. Revised 2026-10-07: ADC's cross-service-freshness gate is now a step of one consolidated freshness deploy job rather than a job of its own, so the gate citation is restated, and the ADC anchors are refreshed after the v1.233.0 release.
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. Revised 2026-09-25: re-anchored the two Blazor Web hosts' MapRazorComponents wiring and named ADC's .Distinct() against Store's non-deduplicating spread in the double-wiring trade-off. Revised 2026-10-06: records the fifth contract member, ContentHeaderComponentTypes, as a third component extension point, and the shell pages the route list omitted. Revised 2026-10-07: records that the delete confirmation bypasses IAppDialogService (the DeleteConfirmation component injects MudBlazor's IDialogService directly), scopes the facades to toasts and confirmation, and re-anchors the ADC Web host's assembly wiring.
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 / OwnsAddress (owned types) 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. Revised 2026-09-25: OwnsAddress ships beside OwnsMoney and Store's Customer.Address maps through it, so no consumer hand-copies an Address owned-type block. Revised 2026-10-06: a serialized Money.Zero() now JSON round-trips, because both currency converters read the empty code back as the sentinel and Money's JSON read refuses that sentinel beside a non-zero amount, so the 2026-10-01 round-trip flag is resolved; anchors refreshed.
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). Revised 2026-09-10: the second gate now ships in both templates (a createDataProtectionKeyVaultKey flag, a dataprotection-kek key, a dataProtectionKeyVaultKeyUri parameter and repository-variable plumbing in each deploy.yml), default off in both, so the record states what ships rather than what a given deployment enabled. Revised 2026-10-06: in both ADC hosts Key Vault configuration now loads before AddServiceDefaults() (still before the registration call), the ADC replica cap is conferenceScaledMaxReplicas, and gate 2 is recorded as switched on for ADC and off for Store. Revised 2026-10-07: anchors refreshed after the v1.233.0 release, and Related now points at ADR-024 for the WebSockets-only SignalR client that needs no session affinity.
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. Revised 2026-10-06: inventory re-counted: thirty-three framework registrations with SecurityHeadersSettings and SameOriginApiProxySettings now on the chain, eleven framework bindings deliberately off it, five custom IValidateOptions<T>, and each Identity AuthenticationService now takes a single IOptions<T>; source citations re-anchored. Revised 2026-10-07: LegalAcceptanceOptions now states its off-chain reason in a comment at the call site rather than only in XML remarks, and anchors were refreshed after the v1.233.0 release.
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). Revised 2026-10-06: the question award runs through BestEffort.ExecuteAsync, the scanner reports a non-badge QR as a skipped outcome, and a rejoin of an erased leaderboard row is refused.
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. Revised 2026-09-11: the tenant crosses the broker. A gap this record never listed (a consumer service could not resolve the tenant of an integration event) closes: the outbox row stores the tenant it was written under and the consumer restores MMCA-Tenant-Id before the inbox check, with the tenant set only when the scope has not already resolved a different one. Revised 2026-10-01: The Decision no longer claims the outbox row has no tenant column, and the tenant-unaware factory is now PhysicalDbContextFactory. Revised 2026-10-06: the background-work targets come from TenantDataSourceTargets.Expand (not ExpandRelational), and the DB-per-tenant section now names the second context guard and the per-row outbox scope.
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. Revised 2026-10-06: the smart-wait and cron-validation statements now match the runner's actual behavior. Revised 2026-10-07: the consumer-adoption statement now names every host that calls AddScheduledJobs and the combined audit-trail migrations that create the table.
075 Audit trail (field-level change history) A further 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. Revised 2026-09-07: Store pins the reverse [Pii] convention (everything Anonymize() overwrites must be marked) with a fitness test, and deployed SQL auditing backstops the application-owned trail. Revised 2026-09-25: both apps' SQL auditing records UPDATE and DELETE against dbo.AuditTrailEntries, by different means (Store: server-level BATCH_COMPLETED_GROUP; ADC: database-level object-scoped UPDATE/DELETE ON dbo.AuditTrailEntries BY public on the trail databases, pinned by SqlAuditConventionTests), so the trail is tamper-evident though not tamper-proof. Revised 2026-10-01: Retention has no fallback: without the scheduler nothing is purged. Revised 2026-10-06: inserts and deletes write one summary row, a store-generated key is fixed up by an UPDATE after the save, and ADC's trail-DML auditing therefore fires on routine inserts too. Revised 2026-10-07: the key fix-up issues one filtered UPDATE per pending row rather than one statement covering all of them, AuditTrailSettings also carries DataSource (the engine the reader queries), and anchors are refreshed after the v1.233.0 release.
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. Revised 2026-10-06: the export base's role hook is named as shipped, HasExportPrivilege.
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. Revised 2026-10-01: Single-use records read through an L1-bypassing GetFromSharedStoreAsync, and consumers opt in through a Redis-guarded wrapper. Revised 2026-10-06: the ADR-029 registration counter also reads through GetFromSharedStoreAsync, so the member is no longer single-use-only. Revised 2026-10-07: the login-lockout flag and the soft-deleted-user marker also read through GetFromSharedStoreAsync (seven callers), and the source anchors are refreshed after the v1.233.0 release.
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. Revised 2026-10-06: the export is fail-closed since v1.218.0 (an unscoped derivative must opt in through AllowUnscopedExport or get 403), and since v1.219.0 each page is rendered into memory and written to the response with awaited writes only.
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. Revised 2026-09-25: the ForwardedHeaders step builds from CommonForwardedHeaders.Create(), the same posture the Blazor UI hosts adopt through UseCommonUiForwardedHeaders(). Revised 2026-10-06: the soft-deleted-user check is recorded as still honoring the shared deleted-user marker on hosts without a validator, rather than as a no-op there.
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; and a non-gating cache-purge step follows the gate. Revised 2026-09-10: ADC's activation tier is now one shared 30 x 20s budget over the whole fleet (attempt loop outside, apps inside, break on the first fully-activated pass), matching Store, so the ceiling is about ten minutes regardless of app count and the rollback loop stays reachable inside the 40-minute job; and the deploy needs lists are eleven in Store and twelve in ADC, which adds ai-eval-gate, not "the same ten" in both. Revised 2026-10-07: the four recency gates now run as steps of one freshness job and backend-test-gate is retired, so deploy waits on seven needs in Store and eight in ADC, and the test gate is the pull request's required checks.
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. Revised 2026-10-01: Store's SQL check now reads the service objective and accepts only Basic or Standard S0, closing the S0-to-S3 gap. Revised 2026-10-06: ADC's dated conference-mode window is recorded as a sanctioned per-repo difference in the gate, and Common's workflow list now includes load-tests.yml. Revised 2026-10-07: Common's workflow list is three files with no Claude workflows, ADC's Notification app is recorded as staying off the conference-mode replica ceiling, and backend-test-gate is recorded as gone from both deploy conditions; anchors refreshed after the v1.233.0 release.
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. Revised 2026-10-06: Session now raises SessionChanged from four sites (a second Updated from UnassignRoom), and the stale source citations were re-anchored.
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. Revised 2026-09-07: the webhook action carries [RequestSizeLimit(1_000_000)], the auto-minted signing secret is shared through the distributed cache, cancelling a PaymentInitiated order expires the Checkout session and refuses an already-paid cancel, and reconciliation sweeps PendingPayment/PaymentFailed orders past StuckAgeMinutes to Cancelled. Revised 2026-10-06: the minted signing secret is never printed (an operator reveals it in the Stripe Dashboard), and the paid-order short-circuit covers every status past Paid.
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: 48 aliases across 10 files in the four repos, 46 of them resolving to int, so the compiler sees one identifier type with 46 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,767 lines across 1,192 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. Revisited by ADR-115 (2026-09-09): none of the three triggers fired and the deferral stands, so what is deferred is the default rather than the capability; the wrapper struct now ships as an opt-in framework surface, so the second trigger (a greenfield consumer) costs one DI call instead of a research project, and adoption in the four Source trees is still zero. Revised 2026-10-06: the migration-surface census was re-measured to 3,945 lines across 1,227 files (.
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. Still verified absent today (no MassTransitStateMachine / ISaga in any repo), though one of the three properties, a per-instance deadline, arrived on 2026-09-11 as an internal-command row per unpaid order in MMCA.Store rather than as a state machine: no workflow state outside an aggregate and no ordered compensation, so the trigger is unmet and the deferral stands. ADR-054's compensation plus outbox retries suffice until then, and its sweep stays underneath a coordinator rather than being replaced by one. Revised 2026-10-07: the in-process provider is not MassTransit, so a state machine needs a broker provider (or a new in-process MassTransit registration) to ride, and the EF saga repository would be a fourth MassTransit package that the v8 pin gate covers only once its id is added; the deferral stands.
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. Revised 2026-10-06: the circuit-breaker search now names its one non-breaker hit in HttpResultExecutor.cs. Revised 2026-10-07: the breaker scope now notes that monolith integration events pass through it via InProcessMessageBus (so failing in-process handlers can open it), the database posture names both relational engines and the Sqlite and Cosmos contexts that run on the provider default, the circuit-open log latch is stated per data source, and OutboxProcessor.cs anchors are refreshed.
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). Revised 2026-09-07: health endpoints serve a cached report with single flight (CachedHealthReportProvider, HealthChecks:CacheSeconds) while keeping the rate-limit bypass, and the edge limiter is no longer gateway-only (Store's storefront host limiter plus BoundedCircuitHandler). Revised 2026-09-10: ADC's public UI host carries the same own-host trio (per-IP window chained with a replica concurrency cap, plus a bounded circuit handler), pinned by tests in its gating tier; the tuned numbers differ by app on purpose (ADC 1200 per minute and the framework's 180-second disconnected retention, for shared-NAT conference-day traffic; Store 300 and 60), and the two near-identical local copies are noted as a candidate for extraction rather than a decision to extract. Revised 2026-09-25: the bearer-delegation section records the one edge-authorization difference (ADC evaluates the per-route anonymous policy through AddAuthorization/UseAuthorization; Store declares it and registers no authorization middleware, by design); neither gateway authenticates. Revised 2026-10-01: Both consumers now turn active destination probing off. Revised 2026-10-06: the edge correlation middleware now sanitizes a caller-supplied id, the gateway config filters are recorded as fixed for the process lifetime, and the citations are refreshed.
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. Revised 2026-10-07: Store's gateway comment now agrees with its 15-route table, so the Rationale no longer cites it as a live off-by-N example, and the ADC AppHost and bicep anchors in the Context are refreshed.
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. Revised 2026-10-06: a terminal unresolvable-type miss leaves ProcessedOn null rather than stamping it, so the row stays listed and replayable.
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. Revised 2026-10-01: Redemption serialized under a distributed lock, a reset revokes refresh sessions, and the link carries the token in the URL fragment.
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 against its own five looser ceilings (LCP 4000, FCP 3000, TTFB 1500 ms, CLS 0.1, INP 500, stepped down from an opening 8000/4000/0.25 set and headed for the package default) as a catastrophic-regression backstop, and its components page is the one place an interaction is driven, through the extension block's second member. 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. Revised 2026-10-06: both apps drive their INP interaction through MeasureWebVitalsWithInteractionAsync with a failing visibility check, and the gallery now gates on the good band with a 200 ms INP ceiling sampled on all three pages. Revised 2026-10-07: both apps removed backend-test-gate from their deploys, so ADC's deploy now carries two conditional gates (e2e-gate, ai-eval-gate) and Store's one (e2e-gate), and MMCA.Common's ui-e2e job no longer uploads an E2E coverage file, only Playwright traces on failure.
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. Two runtime properties were recorded as undecided rather than decided (a floating 10.0 base tag under an application layer pinned by commit sha, and all eleven containers running as root), and both are now closed: every image is digest-pinned and every final stage runs USER $APP_UID. What stays open is the gate, not the image: Trivy scans each pushed image on every deploy but report-only in both consumers, so a base-layer CVE is reported by nothing that stops a rollout. 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. Revised 2026-09-07: base images are digest-pinned, every final stage runs USER $APP_UID, image builds restore --locked-mode, and Trivy scans the built image. Revised 2026-09-10: the locked-mode inventory is stated per image (nine of the eleven build-stage restores; the two UI images are excluded because their projects set RestorePackagesWithLockFile=false, so there is no lock to check, and every publish restore stays unlocked for the ReadyToRun RID graph), and the Trivy scan is report-only in both consumers, not gating in ADC: its step carries continue-on-error, so the exit-code: '1' marks the step failed without failing the job. Revised 2026-09-19: the body states the closed postures in the present tense; the section is retitled "Runtime postures (closed 2026-09-07)". Revised 2026-09-25: every deploy.yml citation re-anchored (Trivy step included); Store's report-only Trivy scan runs only on an image leg that rebuilt, ADC's on every leg; the locked-mode inventory is unchanged. Revised 2026-10-06: the Trivy scan is a recorded non-gating decision in both consumers and runs on every leg, Store's re-tagged legs included (so the 2026-09-25 note above no longer holds); ADC container sizing is no longer uniform, because conferenceMode scales four apps up and Identity further for conference day while the 0.25 vCPU / 0.5 GiB baseline still applies outside it; and the build job's per-image gate (build only a changed image, re-tag the rest) is described. Revised 2026-10-07: ADC's unchanged-leg re-tag also refreshes latest against the ACR purge, the SBOM step is recorded as pull-request-only, the publish-property citation is corrected, and every deploy.yml and main.bicep anchor is refreshed after both workflow prunes.
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 twenty 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. Revised 2026-09-19: the read half now goes through an optional client IUiReadCache (a fresh entry answers with no HTTP call, only successful responses are stored, and every successful write invalidates the endpoint prefix, the client half of ADR-040), and UpdateAsync sends the DTO's concurrency token as an If-Match header on the same per-operation HttpClient that carries the idempotency key (ADR-035); the adoption inventory is recounted at nineteen EntityServiceBase services, nineteen taking AuthenticatedServiceBase directly, and twenty list pages. Revised 2026-10-01: Blazor Server per-circuit UI state is recorded as a fitness-enforced rule, and Store's CartStateService now derives from AuthenticatedServiceBase. Revised 2026-10-06: the retry policy is recorded as never replaying a POST or PATCH that carries no Idempotency-Key, write invalidation as skipping only a pre-write rejection, and the direct-root count as twenty-two. Revised 2026-10-07: the opt-in same-origin proxy base address and handler, the shared Aspire handler's refusal to retry POST and PATCH, and the IdempotentReadRetry GET path for read services outside the hierarchy are recorded, and the IsNotFound anchor is corrected.
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. Revised 2026-10-06: the per-engine predicate now comes from each engine's IDataSourceEngine.BuildSoftDeleteFilter, and the Cosmos no-op is a relational-capability check.
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). Revised 2026-10-06: adoption is thirteen call sites with the ADC points award, and every Store Catalog controller evicts both catalog tags.
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. Revised 2026-09-07: a password change or reset revokes the account's whole refresh-session family through RefreshSessionRevocation, wired by an optional IRefreshSessionStore on the two handler bases. Revised 2026-10-06: the session workflow now lives in the sealed AuthSessionIssuer behind a breaking AuthenticationServiceBase constructor change (1.218.0), a signed-out or cap-evicted token no longer revokes the family, an already-revoked revoke-by-id answers 404, and both page revokes confirm first. Revised 2026-10-07: the reuse grace window this record originally rejected is adopted (1.233.0): a token rotated less than RefreshSessions:ReuseGraceSeconds ago (default 10) answers 409 Auth.RefreshSuperseded and revokes nothing, on both the lookup path and the lost rotation claim, so the 2026-09-01 note that a lost claim is answered exactly like a replay now holds only past the grace and only when the row was not signed out or cap-evicted.
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 is confined to the framework's AppHost test tier, 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 bounded exception is sanctioned, and it is a framework tier rather than one repo's test: Aspire.Hosting.Testing is pinned in MMCA.Common only, MMCA.Common.Testing.Aspire owns the only builder call (ADR-117), and is exercised by MMCA.Common's blocking apphost-testing CI job over a sample AppHost, while each consumer's real AppHost is booted by its E2E workflow on every E2E run, 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). Revised 2026-10-06: the ADC AppHost's broker is described as selectable (RabbitMQ or the Service Bus emulator) rather than RabbitMQ only. Revised 2026-10-07: the consumer AppHost smoke projects and their nightly job are removed.
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. Revised 2026-10-06: the payment override now runs on the shared mutate workflow with its provider call inside MutateAsync, so the generic-path boundary is the generic update path, not the presence of a provider call.
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. Revised 2026-10-06: Decision 1 now scopes the explicit-value-wins rule to the in-process transport, as its cited source does.
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. Revised 2026-10-07: the lockstep rationale now says the metapackage sets its six dependencies as minimum-version floors rather than exact pins, and the host claim says application hosts depend on all six mostly transitively; anchors refreshed after the v1.233.0 release.
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. Revised 2026-10-06: the app-side forwarding passage now names the seeder service-locate, and the legacy-removal search cite is narrowed to the one matching test line.
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. Revised 2026-10-01: The base now registers the real authorization service, so a component test can assert a role denial. Revised 2026-10-06: the helper's call-site count refreshed and two omitted registrations recorded (the base's ViewerTimeZone default and the ADC Conference subclass's inert event lookup).
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). Revised 2026-09-09: ADR-115 copies this record's posture verbatim for strongly typed identifiers: the framework ships the capability complete and tested (77 executed tests), wired into JSON, EF Core, MVC binding, OpenAPI and filtering, adopted by nothing, with the cheap default (the ADR-048 primitive aliases) left exactly where it is. Revised 2026-10-07: Decision point 13 now separates code references from comment-only mentions of Enumeration<T>; adoption remains zero.
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). Revised 2026-10-01: Region matching is a whole-token comparison rather than plain containment. Revised 2026-10-06: the gate runs only on a code diff, so a Markdown-only pull request skips it.
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-19 across MMCA.Common/Source: 86 extension blocks in 69 files, 24 of them extension(IServiceCollection services) spread over eleven 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. Revised 2026-10-06: counts re-measured against current source, and ADC's broker selection is now credited to the framework rather than to an ADC block.
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 eight ITransactional commands (six ADC, two Store) plus direct callers in the session store and both auth services. Revised 2026-09-25: ADC has six ITransactional commands, and Store's CheckOutHandler documents the commit-once behavior citing this record. Revised 2026-10-01: The commit phase now saves enrolled internal-command rows and refuses any other unsaved tracked change before committing, and MMCA.ADC's count is five with RefreshFromSessionizeCommand a documented opt-out. Revised 2026-10-06: the commit phase also releases the internal-command processor wake after a successful commit and drops it on every other path, MMCA.ADC's count is six with DeleteSessionAssetCommand, and three more ADC direct callers are listed.
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. Revised 2026-09-25: the absence of a lock in MMCA.Store and MMCA.Helpdesk rests on the rule (points 2 and 11), not only on current state. Revised 2026-10-01: Adoption is four call sites, the fourth being the framework's password-reset token redemption, and the key namespace now defaults to the host's application namespace. Revised 2026-10-06: ADC's service ceiling is now 2 replicas by default and 4 in conference mode, and the Redis lock suite has seven cases, not six. Revised 2026-10-07: adoption is five call sites, the fifth being the framework's OAuth exchange-code redemption in OAuthControllerBase, which ADC's OAuth controller wires with the lock.
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. Revised 2026-10-06: module-name consumers moved to the per-engine data-source engines, PostgreSQL added. Revised 2026-10-07: Decision 8 narrowed to the sub-folder names that actually shift the derived module, the stale exemption-prose trade-off dropped, anchors refreshed after the v1.233.0 release.
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. Revised 2026-09-25: the section 10 carried-score caveat is discharged for all three repos.
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 over the framework's governed IChatClient (ADR-120), the model and prompt version are hashed into one prompt contract and persisted with every SessionAiScore row, a two-tier evaluation suite (ai-eval-gate in deploy.needs: golden replay plus prompt contract on every code deploy, the paid live judge only when the diff touches the scoring paths) gates deploy, the untrusted half of the prompt is delimited, escaped and redacted, the response is schema-constrained with refusal, empty and partial responses failing rather than defaulting and the weighted overall computed in C#, the pass runs as an ADR-114 internal command behind a per-event claim, and token spend is metered on the MMCA.Common.AI meter under a budgeted ceiling alert. The real spend control is that the only trigger is a permission-gated organizer click. 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. Revised 2026-10-01: The guardrails are one module-owned composition of three, adding the framework content policy and a response gate that now owns refusal, empty and malformed-answer handling, and both evaluation tiers, golden replay and the live judge, run that same composition. Revised 2026-10-06: the trigger answers 404 for an unknown event before scheduling, failed sessions are retried and re-paid up to the processor's attempt limit, and the paid-tier path filter is stated as the three folders it covers. Revised 2026-10-07: the free evaluation tier runs on every code PR through CI.slnf while ai-eval-gate runs only on a deploy the path filter marks as touching scoring (or that it cannot range-check), the corpus guard names three required cases, and the port's prompt-version remarks exclude redaction.
112 Catalog owns effective pricing Admin-managed variant discounts modelled as a variant-level owned value object, not a Promotion aggregate. VariantDiscount (percentage off or fixed special price, optional inclusive-start / exclusive-end window, optional 50-char label) is a sealed ValueObject record flattened onto the variant row as nullable columns by an add-only migration (ADR-057); product-wide discounting is all-or-nothing fan-out over the same value, validated against every active variant's list price before any of them is touched. ProductVariant.Price stays the LIST price, so clearing a discount restores it with no bookkeeping, and one method (GetEffectivePrice(now) / ResolveEffectivePrice) is the single place a price is decided, resolved against an injected TimeProvider for both the read model and the cross-module pricing call. Sales stores no cart price and re-fetches at checkout through the existing IProductVariantService.GetUnitPricesAsync, which now answers with the effective price: the gRPC contract, CheckOutHandler, CheckOutDomainService, Order.Create and the ProductVariantChanged event (still carrying the LIST price) are all unchanged, so the feature is additive across the module boundary. The effective price must stay above zero because Sales' order-line validator rejects a non-positive unit price, which is why a zero-priced variant cannot carry a discount. Three endpoints gated on the narrower catalog:pricing:manage permission under ADR-035's two-token rule. Costs: a window that opens or closes on the clock evicts no cache, so a cached storefront read can lag a boundary by up to the five-minute TTL (checkout never does, it is a live call); order lines record neither the list price nor the promotion name; no coupon codes, category-wide promotions or stacking, though a future Promotion aggregate would resolve into the same effective-price method.
113 PostgreSQL as a fourth first-class engine PostgreSQL joins SQL Server, Cosmos DB and SQLite as a peer engine under ADR-006's one-sealed-context-per-engine rule and ADR-018's routing model, because its absence was ending framework evaluations before the architecture was read (named in six of ten surveyed "top .NET backend" lists, and the default engine in most repositories studied). Mechanism: DataSource.PostgreSQL appended to the enum rather than inserted (the three existing members are shipped API with fixed ordinals), a PostgreSQLDbContext built exactly like the SQL Server one (retry-on-failure, per-source migrations assembly, command timeout, suppressed PendingModelChangesWarning), an EntityTypeConfigurationPostgreSQL shim handled in the SAME switch arm as SQL Server so no snake_case convention is imposed and moving an entity between the two is a base-class change, one SoftDeleteFilterSql.Build answering "IsDeleted" = false and one QuoteColumn / IncludeColumns pair building every partial-index predicate (PostgreSQL rejects the bracketed SQL Server identifiers SQLite tolerated), and every DateTime mapped to timestamp with time zone through a model-scoped UtcDateTimeConverter rather than the process-wide Npgsql.EnableLegacyTimestampBehavior switch, which would reach connections the framework does not own and store timestamps without a zone. A PostgreSQL source migrates only when it names a migrations assembly (the SQLite rule, not SQL Server's); readiness gets an AddNpgSql check and the AppHost a WithPostgreSQLDataSource helper, with no Aspire client integration for symmetry with the SQL Server path. Proved by a Testcontainers tier outside MMCA.Common.slnx on its own postgresql-integration CI job, because all three engine differences build a perfectly valid EF model and fail only against a real server. Costs: Npgsql.EntityFrameworkCore.PostgreSQL in every consumer's lock file and a provider that versions on the EF MAJOR rather than the shared patch, four-member switches everywhere the engine set is enumerated (IDE0072 makes a missed one a build error), and no consumer running on it yet: the Helpdesk canary and the --database postgresql template axis are deliberate follow-ups to the release. Revised 2026-10-01: The Helpdesk canary and the --database postgresql template choice are shipped, the canary as an advisory template-generated job. Revised 2026-10-06: engine behavior is delegated to a PostgreSQLDataSourceEngine in the DataSourceEngines registry rather than per-site switch arms.
114 Internal commands: a durable job queue on the outbox's machinery Deferred work is an ordinary CQRS command scheduled into a per-source InternalCommands table and executed later through the normal decorator pipeline, closing the gap between the outbox (which carries events, not instructions) and the recurring scheduler (which carries cron occurrences and deliberately drops missed ones). Mechanism: IInternalCommand is a marker over ICommand<Result> with no second handler contract, so InternalCommandDispatcher resolves the CLOSED ICommandHandler<TCommand, Result> and the deferred run passes through feature gating, authorization, logging, cache invalidation, validation, the timeout budget and the transaction exactly as an inline one does (ADR-014); IInternalCommandScheduler adds the row to the scope's own context so a live transaction carries it to commit and a rollback erases it; the table is mapped unconditionally for every relational engine (never gated on a flag, because a schema-changing flag would make enabling the queue a migration) using ADR-113's engine-aware index helpers; execution rebuilds the scheduling principal (user id, roles, tenant, correlation id) into ScopedUserOverride behind an ImpersonatingCurrentUserService decorator, so an IRequiresPermission command is schedulable and the row is a durable record of an authorization decision; and failure policy is the outbox's claim-lease, jittered exponential backoff and dead-lettering, with one divergence (an unresolvable type or a missing handler is terminal on the FIRST attempt, where the outbox retries once). Settings mirror OutboxSettings except ProcessingDelaySeconds (0, there being no in-process fast path to race) and a separate MaxRetryBackoffSeconds ceiling of 600. Costs: one migration per relational source, at-least-once execution now an obligation on ordinary commands, a stored authorization decision that outlives a role change, up to one polling interval of latency for a transaction-scheduled row (hence a 2-second default), and a second poll loop with its own signal and its own MMCA.Common.InternalCommands meter. Hangfire, Quartz.NET and a cloud queue were rejected for the same reason: none can enlist in the caller's transaction. Revised 2026-10-01: A schedule outside a transaction now signals the processor whether or not the row is due, and the two processors share one polling implementation. Revised 2026-10-06: a schedule enrolled in a transaction now owes a processor wake that the unit of work releases once after a successful commit and drops on rollback.
115 Strongly typed identifiers opt in, aliases stay the default The wrapper struct ADR-048 declined and ADR-085 priced and deferred ships as a complete opt-in capability, with the primitive aliases unchanged as the default and zero adoption by design: the posture ADR-104 records for smart enumerations, and the trigger is the 2026-09-09 framework gap analysis rather than any of ADR-085's three. Mechanism: IStronglyTypedId<TSelf, TValue> declares a Value getter and a static abstract From, so a wrapper is a positional readonly record struct plus one factory line; IParsable<TSelf> arrives as EXPLICIT default implementations (the only form an inherited static abstract member allows, so OrderId.Parse(...) does not compile and every framework boundary parses through the interface); JSON is the bare primitive, dictionary keys included, through a factory registered once in AddAPI; MVC route and query binding goes through a TypeConverter registered by TypeDescriptor.AddAttributes, which is the empirical finding of the record (minimal APIs bind through IParsable, MVC does not); EF Core maps the wrapper as a PRE-convention type mapping on the one base context, so all four engines get it with no branch and a wrapped int key keeps the store-generated strategy a model-finalizing convention would silently cost it; the column and the wire stay the primitive, so adopting or reverting is neither a migration nor a contract change; filtering supports the equality family only (a record struct has no >), sorting is untouched, OpenAPI documents both schemas and parameters as the primitive, and Mapperly needs nothing unless a contract keeps primitives on the wire. One AddStronglyTypedIds(assemblies) call is the whole opt-in, and a fitness rule holds the first wrapper any repo declares to the shape the converters were written against. Costs: two identifier styles can now coexist with nothing preventing it, 77 executed tests against fixtures rather than a production module, no ordering filters on a wrapped column, 55 new public declarations frozen at the next release under ADR-015's baselines, assemblies named up front because EF needs the type list before any entity exists, and a process-global TypeDescriptor registration. Revised 2026-10-06: the string identity-path rationale was corrected, since System.String does implement IParsable<string>.
116 Identity completions as opt-in bases, not features The four missing pieces of a production identity module (a second factor, email confirmation, operator-editable permission grants, and a user and role administration API) ship as contracts and abstract bases behind their own Add* calls, never as framework features with framework tables, because the user row is the app's (ADR-032's narrow-contract precedent), a mandatory sign-in step is a behaviour change in two deployed systems, and pages are where apps diverge most. Mechanism: ITwoFactorService is stateless cryptography (secrets, provisioning URIs, skew-windowed verification, a RecoveryCodeSet of plaintext plus hashes) over a consumer-implemented ITwoFactorStore and an ITwoFactorUserState on the app's own User; the sign-in hook is an OPTIONAL constructor argument on AuthenticationServiceBase, so an unadopted consumer pays not even a query and every subclass keeps compiling; a verified challenge stamps an amr-style mfa claim that IRequiresMfa and the shared AuthorizationGate test for presence, rather than widening ICurrentUserService; email confirmation mirrors ADR-091's token service member for member under its own emailconfirm: prefix and gates nothing until RequireConfirmedEmail is set AND the app's user implements IEmailConfirmableUser; stored grants union with ADR-020's compiled registry through a decorator with no deny row, read from a per-role IMemoryCache snapshot because HasPermission is synchronous; and the administration API is two controller bases gated on users:manage / roles:manage capabilities, the roles half shipping filled in because both its halves are framework-owned. One new package (Otp.NET, Infrastructure only). Costs: every adopter writes its own ITwoFactorStore, a grant edit reaches other replicas within CacheSeconds (300 by default), EFPermissionGrantStore joins the hard-delete allowlist consumers must extend, and the PermissionGrants table is a migration in the consumer's Identity database. Revised 2026-10-01: Item 10 now records that MMCA.Common.UI ships the email confirmation page. Revised 2026-10-06: item 13 records versioned Terms of Service acceptance as a fifth opt-in completion of the same shape.
117 AppHost integration testing as a shipped package The AppHost states how a whole stack fits together and nothing compiled a claim about any of it: the solution build type-checks it without running it, every in-process tier boots hosts directly through WebApplicationFactory, and E2E only sees a composition mistake once it is deployed. MMCA.Common.Testing.Aspire turns ADC's hand-rolled smoke project (roughly a hundred lines of budgets and poll loops guarding one assertion, with Store owed the same copy) into a subclass plus assertions. Mechanism: a collection fixture over DistributedApplicationTestingBuilder, readiness awaited per resource through WaitForResourceHealthyAsync (or Running for a resource carrying no health annotation) inside ONE shared deadline rather than a per-resource allowance, separate startup and readiness budgets because they fail for different reasons, and preconditions that produce a named skip instead of a wedge: an opt-in MMCA_APPHOST_TESTS variable, a socket-probed container runtime, and a development certificate that is DETECTED and never installed. The fixture mints an ephemeral RS256 keypair into E2E_JWT_* when the environment has none, which is the fix for the 2026-09-01 ADC nightly (no key material made Identity answer 500 on /alive, so every WaitFor(identity) edge timed out). Assertions state the framework's wiring contracts: health, /alive, readiness, a JWKS set that must not be empty, an exact-HTTP/2 h2c probe, and a data-source key that must exist and parse (opening it would need an ADO provider per engine and would prove nothing the resource's own health check does not). The package's layer ceiling is MMCA.Common.Testing, enforced by an MSBuild target reading DECLARED references and by an assembly-level fitness test, with MMCA.Common.Aspire forbidden and its probe paths MIRRORED under a cross-asserting unit test. Costs: the slowest tier per assertion, deliberately continue-on-error like ADC's under ADR-098; a consumer's real stack still needs Docker; the package count moves 17 to 18 and Aspire.Hosting.Testing couples to the same 13.5.3 pin; and the tier cannot prove a SERVICE refuses HTTP/1.1, because Aspire's endpoint proxy answers HTTP/1.1 itself, so that half is asserted against a listener a unit test owns. Revised 2026-10-06: the ADC gateway test asserts liveness (/alive) rather than the aggregate /health, and the package's XML documentation now recommends the safe skip branch. Revised 2026-10-07: the consumer AppHost smoke projects (ADC and Store) and their CI job are removed, so the consumer passages are restated as history, and MMCA.Common's apphost-testing tier is now blocking and remains where the base is exercised.
118 Message transport library policy (MassTransit v8 pin and exit strategy) The transport-library choice becomes a decision with a horizon instead of a clause inside ADR-016's release policy. MassTransit stays pinned at 8.5.10 across its three packages (Directory.Packages.props:106-108) because v9, announced April 2025 and shipped January 2026, is commercial with a runtime licence key: a v9 bus fails its startup licence check and every broker-enabled host crashes, which no build can see because CI never starts a broker. The ceiling is therefore a fitness function (DependencyVersionTestsBase.cs:25, exclusive major 9 at :31), not a comment, after a blanket update reintroduced the crash once. What makes the pin affordable is that the durable machinery is NOT the library's: the outbox row (payload, EventType, the LockedUntil/LockToken lease, retry count, trace ids, ordering key) and the inbox (IInboxStore / InboxMessage / EfInboxStore) are framework code, and MassTransit owns only the publish-and-consume leg that OutboxProcessor reaches through IMessageBus. The whole using MassTransit surface is eight files, seven of them inside MMCA.Common.Infrastructure, with Domain/Application/Shared holding only IMessageBus and IEventBus and MicroserviceExtractionTestsBase banning the library outside API and Infrastructure. Decision: keep v8, keep the outbox and inbox custom precisely BECAUSE that keeps a swap cheap, and name the exits now: a v8 advisory with no patch, a .NET major v8 does not support, or the close of the v8 maintenance window. Replacement order is Wolverine (Apache 2.0, handler methods, RabbitMQ/Azure Service Bus/SQS/Kafka, its own outbox left off) then a raw-SDK adapter (RabbitMQ.Client plus Azure.Messaging.ServiceBus, already pinned at 7.20.2 for the emulator tier), both behind the existing IMessageBus and trialled as an additive arm in ConfigureBrokerTransport's provider switch. Rejected and recorded so they are not re-proposed: MassTransit v9 (buying a licence retires the gate rather than answering it) and MediatR (not used anywhere here, commercial since 2025). Cost: a finite support horizon accepted deliberately, an emulator fixture coupled to the v8 custom-clients overload, and candidates ordered on licence and transport coverage rather than on a spike anyone has run. Consequence: an adapter spike is a Common-only change with no consumer code change.
119 Delete behavior is an explicit business rule (Restrict by default) EF picks a delete behavior when nobody does: required relationships cascade, optional ones null out on the client, and neither choice appears anywhere a reviewer reads. Across all three apps exactly TWO were ever written down (ADC's Session-to-Room Restrict, Store's product image data Cascade); every other foreign key took the default and every default is already encoded in the deployed migrations. Soft delete (ADR-005) means those cascades never fire at runtime today, which is the argument FOR fixing it rather than against: the hazard is invisible until the first hard delete, data-repair script or DBA cleanup, and the FK constraint is what a migration writes regardless. Mechanism: RestrictDeleteByDefaultConvention, a model-finalizing convention registered once in ApplicationDbContext.ConfigureConventions (last of the three, so it never stamps a relationship the cross-source convention removed), sets Restrict on every FK whose delete behavior has no configuration source or only EF's own, never overrides a configured one, leaves ownership FKs to EF, no-ops on Cosmos, and stamps every FK with MMCA:DeleteBehaviorSource = Explicit / Convention / Ownership so a finished model is auditable without re-running it. Two fitness functions read that stamp back (DeleteBehaviorConventionTestsBase): any Cascade/ClientCascade must be Explicit, any Convention-sourced FK must be Restrict, asserted against dbContext.Model so no database server is needed. One registration on the one base context per engine (ADR-006) reaches every module, database and repo. Costs: one migration per service database at the next Common bump (four in ADC, three in Store, one in Helpdesk); optional relationships also move from ClientSetNull to Restrict; a real cascade now costs a line and a reason; SQL Server's multiple-cascade-path limit only bites where a config opts IN, which is the one place it is diagnosable; the rules assert the model rather than an already-deployed schema; and Cosmos entities get no coverage by construction. Common's own tables declare no relationships between each other, so nothing flips inside the framework and its gate passes vacuously until the day one of them gains a relationship.
120 Governed language-model boundary (MMCA.Common.AI) Rubric section 16 asks that a feature calling a model be isolated, versioned, evaluated, observed and bounded; ADR-111 earned all of that for ADC's session scoring inside one class in one module of one repo, so a second AI feature would inherit the rules by imitation. The framework now owns the boundary as one optional package, MMCA.Common.AI, with no MMCA project reference (FrameworkReference plus Microsoft.Extensions.AI 10.10.0 and the official Anthropic SDK 12.47.0, MMCA.Common.AI.csproj:11,29-30) and the direction gated both ways: EnforceAiLayerBoundary (MMCA.Common.LayerEnforcement.targets:86-100) fails the build if the package reaches into Domain/Application/Infrastructure/API/Grpc/UI, and AiDependencyIsolationTestsBase keeps Anthropic/Microsoft.Extensions.AI/OpenAI/Azure.AI and MMCA.Common.AI itself out of every layer that is not Infrastructure. The provider half is the SDK's own AsIChatClient adapter, never a hand-rolled HttpClient; the governance half is a delegating-client pipeline, outermost first: BoundedChatClient -> UsageRecordingChatClient -> optional DistributedCache -> OpenTelemetry -> Logging -> provider. BoundedChatClient clamps output tokens, runs every call on a linked-token timeout, strips tools while Ai:AllowTools is false, and refuses a request over Ai:PerCallInputTokenBudget on an ESTIMATE. PromptContract makes a prompt a versioned contract whose line-ending-normalized SHA-256 Hash (name, version, model, system prompt) is what a golden-replay gate keys recorded answers on, stamped onto every request as mmca.prompt.name/version/hash. AiUsageMeter publishes mmca.ai.input_tokens / mmca.ai.output_tokens on ONE framework-wide meter (MMCA.Common.AI) tagged model/prompt_name/prompt_version/provider, so a spend dashboard is one query across every service. With Ai:Enabled false nothing is registered, so consumers gate on GetService<IChatClient>() rather than on a flag. Consequences: ADC migrates AnthropicScoringService to IChatClient at its next Common bump (the port, PromptVersion and the eval gate all stay; the per-service meter MMCA.ADC.Conference.Scoring is replaced, so its dashboard and alert queries move), Store and Helpdesk adopt nothing until a feature of theirs calls a model, and section 16 re-scoring is expected to move ADC on "observed" and "bounded". Revised 2026-10-07: PiiRedactionGuardrail also redacts tool results and tool-call arguments that arrive as JsonElement values (since v1.232.0), and its anchors and the CHANGELOG release anchors are refreshed.
121 Ephemeral in-process work queue (bounded channel + hosted drain, supersedes 052) Ephemeral, best-effort work owned by one process runs as a bounded 1024-item Channel<T> singleton (DropOldest, the one full mode) plus a SingleReader BackgroundService drain the host can cancel and await, never an untracked Task. TryWrite always succeeds under DropOldest, so the itemDropped callback wired to a counter and a Warning is the only signal a broadcast was discarded. Post-commit enqueue takes two shapes: a domain-event handler riding the ADR-003 deferral, or a command handler after its save returns (structural for MutateEntityHandlerBase subclasses via OnMutatedAsync, hand-kept in SubmitQuestionHandler, where the rule is "stay below the CreateUnderClaimAsync call"). Anything expensive, paid or must-run goes to ADR-114 instead: durable, leased, retried, dead-lettered, deduplicated cross-replica by an IDistributedLock claim, accepting with 202 and silently coalescing the claim loser. One instance: LiveChannelPublishQueue / LiveChannelPublishProcessor (ADR-039). Revised 2026-10-06: the scoring trigger's event-existence 404 and the handler's current retry remarks. Revised 2026-10-07: the live drain's bounded shutdown flush (complete the queue, then publish what remains within a 5-second budget) and its WaitToReadAsync loop.
122 Dev-only relaxations are environment-gated and fail closed A relaxation applies only when the environment is positively identified as Development; an absent or unparseable environment yields the production posture. SensitiveDataLoggingGate, SmtpTransportSecurity and the AI telemetry gate fail closed on a null environment; CORS, cookie Secure, HSTS, CSP and the pseudo-locale are plain IsDevelopment() checks. Revised 2026-10-06: the plain-check inventory adds the Development-only design-time database skip. Revised 2026-10-07: the inventory names the !IsProduction() OpenAPI and Scalar mappings as outside the rule, the UI-side null-aware pseudo-locale check, and the Serilog file sink as out of scope.
123 Speaker-published session materials Speakers publish decks, handouts and links against their own sessions and anyone downloads them from the public session page. SessionAsset is its own Conference aggregate (not a child of the Sessionize-synced Session), with a server-minted GUID id because that id is a path segment of a public blob name. Authorization is capability OR ownership: conference:session-assets:manage (Organizer/ContentEditor) or the caller's speaker_id claim among the session's speakers, decided in the handlers through SessionAssetAccessService because half the rule is data; the anonymous list applies the BR-49/BR-108 public-session rule and answers an EMPTY list, never 404. Extends ADR-045 from images to documents with three framework additions: DocumentContentSniffer (pdf/pptx/docx/xlsx/zip/txt/md, accepted only when BYTES and extension agree, Office Open XML proven by a [Content_Types].xml entry read by name with a 4096-entry bail-out), BlobNames.SanitizeFileName, and FileUploadOptions.Attachment/Inline stored as the blob's Content-Disposition plus an immutable Cache-Control through an UploadAsync overload that is a default interface member. Storage is a public-read session-assets container on the existing account under {eventId}/{sessionId}/{assetId}/{file}, downloaded straight from blob with no proxy; Defender for Storage on-upload scanning is a bicep param defaulting to false. Blob removal is the post-commit internal command Conference.DeleteSessionAssetBlob.v1 (ADR-114) from four paths (asset, session, event cascade, failed-commit upload). Limits: 50 MB per file, 10 assets per session, and a path-scoped gateway body raise for /SessionAssets/file only. Assets are visible as soon as the session is; a per-event embargo and a "materials posted" notification are deferred. Revised 2026-10-01: The upload-options overload is abstract, and the template enables on-upload malware scanning by default while production read it off on 2026-10-01. Revised 2026-10-06: all three delete paths schedule blob removal inside the delete's transaction, so there is no post-commit tail.
124 The server-rendered UI host is its own hardened edge The Blazor UI is a separate externally reachable origin on its own FQDN, so the ADR-088 Gateway limiter never sees a request to it. Each UI host therefore defends itself: a ceiling on concurrently ACTIVE circuits held by a SINGLETON CircuitHandler (scoped would count to one and cap nothing), refusing by THROWING because OnCircuitOpenedAsync has no refuse return value, which is the one place the Result pattern yields to a framework contract; the count increments-then-rolls-back so two simultaneous opens cannot share the last slot, and floors at zero on close so an uncounted close cannot hand out permits forever. Beside it, CircuitOptions disconnected retention tightened from 100 to 25 (defence in depth only: it bounds nothing that is open and connected), and a host-local per-IP fixed window CHAINED with a replica-wide concurrency ceiling, both 429, neither queueing. Probes, framework assets and extension-bearing paths are exempt so a page load and a liveness poll never count; /_blazor deliberately is not, because negotiate is what opens a circuit; an unresolvable IP fails open. Both hosts consume one framework kit in MMCA.Common.UI.Web with the UiRateLimiting / BlazorCircuitLimits sections and 200/200 defaults, kept separate from MMCA.Common.Gateway (the reverse-proxy kit) and tuned apart in configuration: ADC 1200 requests/minute against a venue behind one NAT and 180s reconnect retention, Store 300/minute and 60s. Both ceilings are per replica and in memory; the kit moves both hosts together at the lockstep bump. Revised 2026-09-25: the kit is one framework copy in MMCA.Common.UI.Web/Hardening/ (v1.206.0), /hubs is a kit default, and both hosts adopt forwarded headers through Common's UseCommonUiForwardedHeaders(). Revised 2026-10-01: The /_blazor circuit transport is held to the per-IP window only and kept out of the concurrency ceiling, and the circuit count releases a permit only for a circuit it admitted. Revised 2026-10-06: the extension exemption no longer applies under the same-origin API proxy prefix, proxied hub traffic is exempt at the configured prefix, and ADC's replica count, container size and per-IP window widen under conferenceMode. Revised 2026-10-07: under conferenceMode ADC's UI runs a fixed four replicas rather than scaling up to four, ADR-079 is cited as the service pipeline the UI host composes around rather than one it joins, and anchors refreshed after the v1.233.0 release.
125 Parameterized SQL only, enforced by a fitness gate Hand-written SQL has exactly one door and its signature makes the unsafe call uncompilable: IRawSqlQueryExecutor (MMCA.Common.Application) exposes QueryAsync<T> and QuerySingleOrDefaultAsync<T> typed FormattableString and nothing else, so a concatenated statement does not bind to either method and every interpolation hole reaches the server as a command parameter (injection becomes a compile error, and the stable statement text keeps the server's cached plan). The EF implementation is internal, registered scoped one line below the singleton IQueryableExecutor, runs on the host's default physical source through the scope's own IDbContextFactory context (so it shares the caller's connection and any ITransactional transaction), and throws NotSupportedException naming Cosmos DB when that source is not relational (ADR-018). The convention is executable rather than documented (ADR-015): RawSqlConventionTestsBase.ModuleCode_UsesParameterizedSqlOnly scans every mapped module's project directories and fails on FromSqlRaw, SqlQueryRaw, ExecuteSqlRaw or ExecuteSqlRawAsync, with a failure message that names the four members and their replacements, and a non-empty-directories guard so the scan cannot go vacuous. All three repos subclass it: MMCA.Common redirects the scan onto the framework's own Application and Infrastructure, MMCA.ADC appends the unmapped Notification module, MMCA.Store takes the base scan. The AllowedFiles ratchet holds ONE entry across the three repos (Common's DbContextFactory.cs, for SET IDENTITY_INSERT, whose T-SQL identifiers cannot be command parameters and which carries the matching S2077 suppression); ADC and Store are both empty, so the rule blocks every new raw call site rather than ratcheting down. Limits are recorded: FormattableStringFactory.Create can still smuggle a concatenated string, the gate is a textual scan (no IL or Roslyn), AllowedFiles matches on file name rather than path, and the Cosmos refusal is a runtime exception. Revised 2026-10-06: the relational limit is now a conditional registration that fails at container validation, not a NotSupportedException on the first statement. Revised 2026-10-07: the Cosmos-default failure is an unresolved-service error raised at startup only where the container is validated on build (Development under the default builder) and otherwise on first resolution, and the CHANGELOG anchors are refreshed after the v1.233.0 release.
126 Event sourcing is not adopted Weighed 2026-09-22 and rejected: aggregates are current-state EF rows that raise domain events for notification, carry audit fields and soft-delete, and nothing in ADC or Store needs replayable per-aggregate history or "state as of" queries, so a per-aggregate event log would duplicate the outbox and add a second persistence model, a projection layer and a rehydration path across every aggregate. Marten on PostgreSQL was the alternative weighed. Revisit when an aggregate acquires a business requirement for full replayable history or temporal queries, and adopt it for that one module only. Revised 2026-10-06: the ADR-075 history claim is scoped to opted-in entities, with retention purging only where the host runs the scheduler. Revised 2026-10-07: the rationale acknowledges ADC's append-only points ledger and why it is not event sourcing, and the Marten rejection separates framework PostgreSQL support (ADR-113) from production, where no deployment runs a PostgreSQL server.
127 The actor model is not adopted Weighed 2026-09-22 and rejected: no workload has high-cardinality per-entity in-memory state with single-writer semantics, since live polls, bookmarks and session state are served by EF behind the row-version concurrency token plus Redis output caching plus the SignalR hub, and the load is a single conference's attendance. Microsoft Orleans hosted under Aspire was the alternative weighed. Revisit when a real-time per-entity state workload appears that optimistic concurrency plus Redis cannot carry, and host Orleans per module behind the existing module contract. Revised 2026-10-06: the replica wording now reflects that the ADC front-door apps raise minReplicas to 2 under conferenceMode. Revised 2026-10-07: the replica wording now reflects that the ADC UI app holds four replicas under conferenceMode and the Notification app stays at a ceiling of two, and anchors were refreshed.
128 Time as an input Domain and Application code never reads the ambient clock; time arrives through an injected TimeProvider, with BaseDomainEvent.DateOccurred the single framework exemption. An IL-scanning fitness rule shipped as ClockReadTestsBase bans the clock getters; MMCA.Common, MMCA.ADC and MMCA.Store run it, MMCA.Helpdesk does not yet. Revised 2026-10-06: async-lambda clock reads are attributed to, and exempted with, their source member.
129 The tactical aggregate contract One entity contract: sealed classes over BaseEntity / AuditableBaseEntity / AuditableAggregateRootEntity, construction only through a Create returning Result<T>, no public setters, child access through the root, invariants in static *Invariants classes composed with Result.Combine. EntityConventionTestsBase and the naming rules gate construction, sealing, setters, placement and static invariant classes; child access and Result.Combine stay convention.
130 Per-engine data source strategy One internal IDataSourceEngine per engine (SQL Server, PostgreSQL, SQLite, Cosmos DB), looked up through the static DataSourceEngines.For(DataSource) registry (static because conventions and SQL builders run without a container), so call sites read engine facts instead of branching on the kept DataSource enum. Capabilities collapse to IsRelational plus the few that differ (migrations, required connection string, null ordering, row version), and outbox support folds into IsRelational. RequestIdentityInsert is renamed RequestExplicitKeyInsert; the raw-SQL executor is registered only on a relational default engine; SQLite quotes with double quotes everywhere. Per-engine settings properties kept; a dictionary-keyed settings model rejected 2026-10-01. Revised 2026-10-06: the release status now records v1.218.0 as shipped.
131 Same-origin API proxy (backend-for-frontend) Opt-in per Blazor Web host (TD-08 Option A, MMCA.Common 1.218.0): AddCommonSameOriginApiProxy plus MapCommonSameOriginApiProxy in the server-only MMCA.Common.UI.Web serve /api/** on the UI host's own origin and forward to the gateway through YARP, attaching the bearer read from the existing HttpOnly session cookie, so the browser holds only an unsigned alg:none claims copy that authorizes nothing. Every proxied request, WebSocket upgrades included, must be same-origin by Origin and Sec-Fetch-Site; OPTIONS is answered locally and never forwarded; unsafe methods also need X-CSRF: 1. Stateless (no server-side session store); hubs and file downloads go through it too; a refused refresh clears the cookies and answers 401 while an undecidable one keeps them and answers 503 with Retry-After. Cookies default to SameSite=Strict under the proxy, Lax allowed (CSRF rests on the header and origin gates). MAUI is excluded. Rejected: a same-site cookie straight to the API (shared-domain mandate), stopping at the C+ hybrid, a session store, hosting in MMCA.Common.UI or MMCA.Common.API. Costs: one hop, the UI host becomes an auth edge, its rate limiter meters API traffic, and ingress hosts must call UseCommonUiForwardedHeaders() or every browser POST is refused. Revised 2026-10-06: v1.218.0 is released, ADC adopted it through 1.218.1, HTTP/2 WebSocket CONNECT is exempt from the CSRF header, and proxied hub traffic is exempt from the UI rate limiter. Revised 2026-10-07: the proxied hub connection carries no X-CSRF header and both hub modes are WebSocket-only with negotiation skipped, Unavailable covers every non-refusal status including 409 Auth.RefreshSuperseded, and the implementing commits are on main as squash e9c28d15 (#469).
132 Build strictness and analyzer policy All four .NET repos set TreatWarningsAsErrors, CodeAnalysisTreatWarningsAsErrors, AnalysisLevel latest and EnforceCodeStyleInBuild, reference five analyzer packages (Meziantou, VS Threading, Roslynator, SonarAnalyzer, StyleCop) from root props, and default every analyzer diagnostic to error in a root .editorconfig shared above a # REPO-SPECIFIC DELTAS marker; compare-analyzer-config.ps1 checks that block line by line and a post-edit hook prompts the comparison. CI asserts --minimum-expected-tests on most test jobs (not the ADC and Store browser E2E) and enforces unit-tier line-coverage floors in Common, ADC and Store (Helpdesk has none). The policy predates this record, its first written decision.

Writing a new ADR

Start from docs-src/adr/_template.md in this repo (it is not rendered; the generator only picks up NNN-*.md), which carries the structure every record uses: 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), and Alternatives rejected whenever something was genuinely considered and dropped. 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.

Write the rejection down, with its date and its reason. This is the one part of a decision that nothing else in the repository can carry. The code records what was chosen; it never records what was considered and dropped, so an unrecorded rejection gets re-proposed forever, by each new reader and by every agent reading the source. A rejected option belongs in the record that owns the pattern it would have changed (ADR-016, ADR-029, ADR-041 and ADR-083 each carry one), phrased so a reader arriving with that exact idea recognises it and stops. "Rejected on cost" is not enough: name the alternative, the date it was weighed, and the condition that would make it right to revisit.