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

Onboarding guide

16. Aspire Orchestration & Service Defaults

This chapter covers the hosting boundary: how the distributed MMCA system is composed and run, locally as a single dotnet run, and in Azure Container Apps (ACA) as a set of independent revisions. Four distinct concerns live side by side here, and it pays to separate them up front. AppHost-side code (MMCA.Common.Aspire.Hosting, MMCA.ADC.AppHost) is the orchestrator: it declares the resource graph, containers, databases, broker, the four services, the gateway, the UI, and wires their dependencies. Service-side code (MMCA.Common.Aspire) is the baseline every running process opts into: OpenTelemetry, health checks, service discovery, HTTP resilience, Kestrel listener profiles, startup warm-up, Redis wiring, Serilog, vault-backed configuration, a shared DataProtection key ring, and hardened security headers. Edge-side code (MMCA.Common.Aspire.Gateway) is the small kit a reverse-proxy host adds on top: correlation, an edge rate limiter, and per-downstream readiness. YARP building blocks (MMCA.Common.Gateway) are the fourth: cluster request profiles, destination health-check defaults, route/cluster trace headers, named per-route limiter policies, and the forwarded-headers step, all applied on top of whatever route table the host loaded. The AppHost and service assemblies are deliberately kept apart so a running service never drags in the full Aspire.Hosting tooling graph (MMCA.Common/Source/Hosting/MMCA.Common.Aspire.Hosting/Extensions.cs:13-17), and the service-side package carries exactly one first-party project reference, MMCA.Common.Shared, taken solely so the resilience constants have one home (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/MMCA.Common.Aspire.csproj:75). The gateway package goes further: it has no project reference at all and one runtime package, YARP (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/MMCA.Common.Gateway.csproj:15), because the host it serves has no application container to share. ADC has no app-local ServiceDefaults project: all four services, the Gateway, and the Blazor UI host consume MMCA.Common.Aspire's AddServiceDefaults() directly (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:101, MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:97, MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:84, MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:87, MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:57, MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:33). Everything in this group is plumbing, but it is the plumbing that makes ADR-006 (database-per-service), ADR-008 (service topology + YARP), ADR-009 (resilience / RTO-RPO), and ADR-041 (observability and telemetry) real at runtime rather than aspirational.

The orchestrator: declaring the resource graph

When you run dotnet run --project Source/Hosting/MMCA.ADC.AppHost, Aspire executes Program.cs top to bottom, building a resource model rather than starting anything immediately. It declares a persistent SQL Server container (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:15-16), four databases on it (ADC_Identity / ADC_Conference / ADC_Engagement / ADC_Notification, Program.cs:37-40), Redis (:44-45), a broker chosen at :89-101, and a MailDev SMTP container (:109), then the four service projects (:127, :156, :199, :227), the YARP Gateway with its HTTPS endpoint pinned to port 6001 (:344-355), and the Blazor UI pinned to 6002 (:381-397). All of the cross-cutting wiring vocabulary lives in one reusable, cross-app place: the Extensions static class of MMCA.Common.Aspire.Hosting (Level 1, MMCA.Common/Source/Hosting/MMCA.Common.Aspire.Hosting/Extensions.cs:23). It supplies the fluent helpers, grouped into C# extension(T) blocks by the resource type they attach to (primer §4): the three provisioning helpers AddMailDev (:141), AddMessageBroker (:160) and AddServiceBusEmulatorBroker (:201); the two WithBroker overloads (:252 for RabbitMQ, :280 for the emulator); WithJwksDiscovery (:309); the three CI-only helpers WithE2eRsaKeys (:353), WithE2eRegistrationThrottleLift (:392) and WithE2eGatewayRateLimitLift (:440); and the per-engine data-source helpers WithSQLServerDataSource (:483), WithCosmosDataSource (:512) and WithSqliteDataSource (:537), the last two added for polyglot persistence (ADR-018).

.WithSQLServerDataSource(db, "Conference") (MMCA.Common/Source/Hosting/MMCA.Common.Aspire.Hosting/Extensions.cs:483) is the AppHost manifestation of ADR-006: in one fluent chain it adds .WithReference(database), .WaitFor(database), and injects a single connection-string env var, DataSources__{logicalName}__SQLServerConnectionString (Extensions.cs:490-493). That one entry is the whole configuration: with no top-level connection string the single database a host declares this way also becomes its Default source, so the framework's own tables, the readiness health check and the migrations-assembly lookup all resolve from it, and the logical name collapses onto Default, leaving one context, one change tracker and one migration set per service (the rationale is spelled out in the method's own doc comment, :468-478). The routing types that consume those env vars, DataSourceResolver and EntityDataSourceRegistry, live in the persistence group. The ADC AppHost calls it once per service (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:130,159,202,230). This paragraph is where [Rubric §8, Data Architecture] (each service owns its store, with no shared write path) meets [Rubric §7, Microservices Readiness] (the topology is a declaration in one file, not a rewrite).

The remaining AppHost helpers complete that vocabulary. AddMessageBroker() (Extensions.cs:160) provisions RabbitMQ with the management plugin in a single call (:164, UI at http://localhost:15672), defaulting the resource name to the DefaultBrokerResourceName constant (:28); WithBroker(broker) (:252) attaches a service to it with .WithReference(broker), .WaitFor(broker) and MessageBus__Provider=RabbitMq (:258-261), the env var that the consuming service's AddBrokerMessaging reads to select RabbitMQ over InProcessMessageBus (selection driven by MessageBusProvider / MessageBusSettings, ADR-066). AddMailDev() (:141) provisions the local SMTP sink on two fixed host ports, 1080 for the web UI and 1025 for SMTP (:118, :124, applied at :148-149), because a developer opens the first by hand and every consumer's Smtp:Port setting names the second. WithJwksDiscovery(identity, gateway?) (:309) sets Authentication__JwtBearer__Authority (:335) so the consuming service validates RS256 tokens against Identity's published keys (IJwksProvider / RsaJwksProvider). The non-trivial part, captured in a long inline comment (:321-331), is that it prefers the gateway endpoint over Identity's (:332-334): the three REST services run HTTP/2-only on cleartext (h2c) so gRPC clients can use prior-knowledge negotiation, but the default JwtBearer backchannel HttpClient speaks HTTP/1.1, which Kestrel rejects on an HTTP/2-only endpoint. Routing the JWKS and /.well-known/* fetch through the gateway (which terminates TLS and supports both protocols via ALPN, ADR-008) makes the default backchannel work end-to-end without weakening the services (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:364-366). The other half of that contract is one line below: the issuer Identity stamps into every token it signs is derived from the same gateway endpoint (Program.cs:375), so an Aspire run cannot mint wrong-issuer tokens if the pinned port ever moves. That is the runtime embodiment of the cross-service token validation boundary in ADR-004, with the transport caveat spelled out in ADR-012.

The three E2E helpers are the CI-shaped siblings, and all are no-ops outside CI. WithE2eRsaKeys() (Extensions.cs:353) forwards an ephemeral RSA keypair from E2E_JWT_PRIVATE_KEY_PEM / E2E_JWT_PUBLIC_KEY_PEM onto Identity's Jwt__RsaPrivateKeyPem / Jwt__RsaPublicKeyPem / Jwks__RsaPublicKeyPem (:364-367), returning untouched when either variable is absent (:359-362), so locally and in production user-secrets or Key Vault supply the keys (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:248). WithE2eRegistrationThrottleLift(alsoLiftWhen) (:392) raises Identity's per-IP registration cap to the E2eRegistrationsPerIpPerHour constant of 1000 (:35, injected as LoginProtection__MaxRegistrationsPerIpPerHour at :402-406) when the E2E_LIFT_REGISTRATION_THROTTLE variable named at :42 is set or the caller passes its own trigger (:396-400); a Playwright suite registers far more than ten accounts from one localhost IP, so without the lift the anti-abuse control refuses every register test past the tenth and the failures look like broken registration. WithE2eGatewayRateLimitLift(alsoLiftWhen) (:440) is the gateway-side twin on the same trigger: it raises the edge limiter to 100000 permits and 10000 concurrent requests and the named auth-tight route policy to 100000 (:48, :54, :60, injected at :455-464), because the whole suite arrives from one loopback IP and both the per-IP window and the anti-credential-stuffing policy read it as the flood they exist to stop. Note what those env-var keys are built from: the helper spells the section names as its own constants (GatewayRateLimitingSection at :70, MmcaGatewaySection at :77, AuthTightPolicyName at :84) rather than referencing the settings types, because this AppHost-tier package must not pull in the service-defaults graph to spell one configuration key, and a unit test cross-asserts the constants against the real section names so a rename cannot silently orphan the lift. ADC calls all three, with its forced-WASM flag as the extra trigger (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:248,428,435, with the two independently gated E2E render-mode switches immediately above at :411-426). The Cosmos and SQLite siblings (Extensions.cs:512,537) inject the equivalent per-engine connection-string env vars (ADR-018) and are layered on top of, not instead of, the SQL Server source; no ADC or Store service wires them today.

Broker parity: RabbitMQ by default, the Service Bus emulator on demand

RabbitMQ is a different broker from the one production runs, so anything that only misbehaves on Azure Service Bus (entity-name limits, topic and subscription provisioning, scheduled redelivery pacing) is invisible in the inner loop. AddServiceBusEmulatorBroker(sqlServer) (MMCA.Common/Source/Hosting/MMCA.Common.Aspire.Hosting/Extensions.cs:201) closes that divergence for a host that opts in: it declares a ServiceBusEmulatorResource (Level 0, MMCA.Common/Source/Hosting/MMCA.Common.Aspire.Hosting/ServiceBusEmulatorResource.cs:32) from the official image pinned to tag 2.0.1 (Extensions.cs:107,214-215), publishes both of the emulator's planes (AMQP on container port 5672 and the HTTP management plane on 5300, ServiceBusEmulatorResource.cs:41,44, wired at Extensions.cs:216-222), accepts the EULA the image demands (:227), points it at the existing SQL Server resource for its own state rather than starting a second engine (:228-233), and waits for that SQL Server because the emulator's first act is to create its schema (:237). The image tag floor is not cosmetic: the HTTP management plane shipped in 2.0.0 and MassTransit provisions its whole topology at bus start, so a silent downgrade to a 1.x image would leave the broker unusable rather than merely older (Extensions.cs:101-107). The resource's ConnectionStringExpression ends in UseDevelopmentEmulator=true (ServiceBusEmulatorResource.cs:66-68), which is the marker MMCA.Common.Infrastructure keys its emulator branch off, and AdminEndpointExpression (:80-82) is the second address MassTransit v8 needs to build its administration client. The matching WithBroker overload (Extensions.cs:280) sets MessageBus:Provider=AzureServiceBus plus those two settings (:286-291). One deliberate gap is recorded in the resource's own doc comment (ServiceBusEmulatorResource.cs:25-30): it declares no health check, so a WaitFor on it gates on the container running rather than on the emulator finishing warm-up, and the consuming service absorbs the remainder because MassTransit starts its bus in the background and reconnects.

ADC makes the choice once, at MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:89-101: ADC_BROKER=servicebus selects the emulator, anything else keeps RabbitMQ. Because the two WithBroker overloads take different resource types, the choice cannot be one variable handed to one call, so it is captured as a delegate and applied through BrokerSelection (Level 0, MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/BrokerSelection.cs:14), a one-member extension(IResourceBuilder<ProjectResource>) block whose WithSelectedBroker(attach) (:21) just invokes the captured delegate (:25). That keeps every service's wiring chain reading the same single line (Program.cs:132,161,204,232), which is what stops a fifth service from quietly being wired to the wrong broker. [Rubric §33, Developer Experience] (environment parity available on demand, at a cost the everyday inner loop does not pay).

Startup ordering, health-gated waits, and the gRPC deadlock-avoidance trick

Aspire's WaitFor builds a startup dependency graph from the resource model: a service does not start until the resources it waits on report healthy. What "healthy" means for a project resource is the health check that resource carries, and this is where the h2c profile bites a second time. Aspire's stock WithHttpHealthCheck probes with a default HttpClient, which sends HTTP/1.1, and an HttpProtocols.Http2 Kestrel endpoint answers that with GOAWAY HTTP_1_1_REQUIRED rather than the health payload, so the check never turns healthy and every WaitFor edge into that resource degrades to "the process started". H2cHealthCheckExtensions (Level 1, MMCA.Common/Source/Hosting/MMCA.Common.Aspire.Hosting/H2cHealthCheckExtensions.cs:41) fixes that with WithH2cHealthCheck(path, endpointName) (:110), which registers an H2cEndpointHealthCheck (Level 1, MMCA.Common/Source/Hosting/MMCA.Common.Aspire.Hosting/H2cEndpointHealthCheck.cs:23) against the named endpoint and then associates it with the resource (:128-137, both halves being required for a WaitFor to gate). Three details carry the design. The default path is /alive, not /health/ready (:53): a startup gate must probe liveness, because a readiness endpoint aggregates downstream and warm-up checks and gating startup on it can deadlock the graph. The probe budget is 2 seconds (:66), short because Aspire polls for as long as a dependent resource is waiting. And the endpoint URL is resolved on every check rather than captured at registration (H2cEndpointHealthCheck.cs:71-72,108), with an unallocated endpoint reported as the failure status rather than thrown or passed (:110-120), because Aspire only allocates the endpoint once the resource starts and a pre-allocation poll must not release the wait. The shared probe client's keep-alive ping trio (:46-52) is load-bearing rather than tuning: Aspire's endpoint proxy listens before the target Kestrel does, so a first probe can open a connection that is accepted and never answered, and without the pings every later probe queues onto that one zombie connection forever. H2cHealthCheckRegistry (Level 0, same file :153) makes a repeat registration a no-op: IServiceCollection offers no way to ask which health checks exist and a duplicate key is a startup exception, so GetOrAdd (:185) attaches one ledger to the AppHost's own collection and TryClaim (:168) skips a key an earlier call took.

ADC applies both probe shapes and explains the split in a long comment block (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:308-343): Identity, Conference and Engagement take WithH2cHealthCheck() (:242, :213, :166) because they run Kestrel Http2-only on cleartext, while Notification takes the stock WithHttpHealthCheck("/alive") (:140) because it runs the mixed Http1AndHttp2 profile. Every service gate is /alive, never /health/ready, and the reason is a startup cycle: service readiness includes the warm-up gate, whose OIDC task fetches discovery through the gateway, but the gateway waits for the services. The gateway resource itself stays on /alive (:354) in the sharpest form of the same rule, because its readiness aggregate includes the per-downstream probes and one unreachable downstream would stop ui.WaitFor(gateway) from ever releasing. Only the UI, which nothing waits for, gates on /health/ready (:396). One further subtlety worth internalizing: Conference and Engagement form a bidirectional gRPC pair, and a reciprocal WaitFor would deadlock, so the AppHost deliberately omits the reverse edge (:273, reasoning at :258-272) and lets transient "peer not ready" failures self-heal via the Polly pipeline baked into AddTypedGrpcClient. The same judgement is applied twice more: Engagement to Notification carries no WaitFor because the live-channel push is fire-and-forget (:281), and Identity's two export edges carry none because those peers already wait on Identity for JWKS (:291-292). The typed-client and interceptor machinery, JwtForwardingClientInterceptor and GrpcResultExceptionInterceptor, lives in the gRPC group (ADR-007).

Because a composition mistake in that one file (a renamed resource, a reference that no longer resolves, a WaitFor cycle) is invisible to dotnet build and to every other test tier, ADC keeps exactly one test that boots the real thing: AppHostCompositionSmokeTests (Level 0, MMCA.ADC/Tests/Integration/MMCA.ADC.AppHost.SmokeTests/AppHostCompositionSmokeTests.cs:23). It builds the app model through DistributedApplicationTestingBuilder (:46-47), starts it, then polls the gateway's /health until it answers 200 (:52-59) inside a 12 minute startup budget and an 8 minute readiness budget (:33, :36). One test on purpose: the claim under test is that the composition resolves, not that the gateway is healthy. It is the single sanctioned use of Aspire.Hosting.Testing in the workspace, and it is probational and non-gating (:18-21), which is the bounded exception recorded in ADR-098.

The service baseline: AddServiceDefaults()

Every running host calls one method early in Program.cs: AddServiceDefaults() from the single framework-grade Extensions in MMCA.Common.Aspire (Level 10, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:29). There is no ADC-local copy. AddServiceDefaults<TBuilder>() (:40) is four lines of composition, ConfigureOpenTelemetry(), AddDefaultHealthChecks(), AddWarmupReadiness(), AddServiceDiscovery() (:42-45), followed by one ConfigureHttpClientDefaults block (:49) that applies to every HttpClient the host later creates: typed clients, named clients, the YARP forwarder, and the gateway's own downstream probe clients alike. That block installs the standard Polly pipeline (:59-65), re-adds service discovery to the handler chain (:66), and then replaces the primary handler with a tuned SocketsHttpHandler (:79-87, rationale comment at :68-78). The handler is the clearest [Rubric §31, Cost & FinOps] decision in the codebase: recycling pooled connections picks up ACA replica DNS rollover without a restart, holding idle connections in the pool avoids paying TCP and TLS handshakes on every low-traffic inter-service call, and socket-level keep-alive pings keep the TCP connection warm without generating HTTP traffic, so an idle replica stays on idle-vCPU billing (documented in-code as roughly 8x cheaper than active). EnableMultipleHttp2Connections = true (:86) keeps a single multiplexed HTTP/2 connection from becoming the bottleneck for the gRPC edges above.

One source of truth for resilience numbers

The numbers that pipeline uses are not literals in the Aspire package. They live in HttpResilienceDefaults (Level 0, MMCA.Common/Source/Core/MMCA.Common.Shared/Resilience/HttpResilienceDefaults.cs:10), a static class of read-only properties in MMCA.Common.Shared: a 30 second per-attempt timeout (:13), a 60 second circuit-breaker sampling window (:16), a 90 second total request timeout including retries (:19), one retry beyond the initial attempt (:28), a 10 minute pooled-connection lifetime (:34), a 5 minute pooled idle timeout (:37), and 60 second / 30 second keep-alive ping delay and timeout (:40,43). It sits in Shared for a layer reason: MMCA.Common.Aspire and MMCA.Common.Grpc may only depend on Shared, and before this type existed each package hand-mirrored the values and drifted (10s/30s library defaults on the gRPC side against the tuned 30s/90s on the HTTP side, stated in the doc comment at :3-9). The retry budget is the interesting constant: it is pinned at one deliberately (HttpResilienceDefaults.cs:21-27) because the UI service base classes already own user-facing retries, and a full retry budget re-applied at every hop turns a backend brownout into an up-to-16x request storm at exactly the wrong moment.

Two siblings sit in the same folder and complete the picture. GrpcResilienceDefaults (Level 1, MMCA.Common/Source/Core/MMCA.Common.Shared/Resilience/GrpcResilienceDefaults.cs:12) is what the typed gRPC clients in MMCA.Common.Grpc read (group 13): its timeouts and retry budget are re-exposed from the HTTP defaults so the east-west path can never drift from the outbound-HTTP path (:15-24), while the breaker shape is stated explicitly here (0.5 failure ratio at :27, minimum throughput 10 at :30, a 10 second break at :33) because east-west calls address a peer directly and bypass the gateway's active health checks, so the breaker is the only thing that notices a peer going bad (:3-11). BrokerResilienceDefaults (Level 0, MMCA.Common/Source/Core/MMCA.Common.Shared/Resilience/BrokerResilienceDefaults.cs:24) does the same job for the other outbound transport: the circuit breaker guarding the outbox's broker-publish path. Four properties, a 0.5 failure ratio (:32), a minimum throughput of 10 attempts (:40), a 30 second sampling window (:47), and a 15 second break duration (:55), are read by OutboxProcessor in MMCA.Common.Infrastructure. Its doc comment carries the two decisions worth reading (BrokerResilienceDefaults.cs:8-22): a breaker is needed at all because a broker outage makes each of a 50-row batch's publishes wait out its own transport timeout, so one cycle can spend minutes discovering nothing; and it is deliberately not paired with a Polly retry, because the outbox already owns retry (RetryCount, exponential backoff with jitter, then dead-letter) and a second policy would make a row's effective attempt count an accident of two independent budgets. Three constants files, three transports, no drift: [Rubric §29, Resilience & Business Continuity] (ADR-009) and [Rubric §29, Resilience, Reliability & Business Continuity].

Listeners and probes: one Kestrel profile per host shape

Before a service can answer a health probe it has to be listening on a protocol the prober speaks, and that is not free when the same port serves inbound gRPC. KestrelEndpointExtensions (Level 1, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Kestrel/KestrelEndpointExtensions.cs:24) turns both deployed profiles into a single call, ConfigureEndpointsWithHealthProbe(defaultProtocols, redeclareCleartextEndpoint, cleartextPort) (:77). It applies the requested protocols to every Kestrel endpoint default and then declares the explicit listeners computed by BuildListenerPlan (:84-98, plan at :113). The plan is empty when HealthProbe:Port (:31) is unconfigured (:119-122), which is exactly the local and integration-test case: no explicit Listen call, so Aspire's dynamic ports keep working and two co-hosted services cannot collide. When the deployment injects the port, the plan is one or two KestrelListenerSpec records (:132), each a (Port, Protocols) pair: an Http1-only listener on the probe port, preceded by a re-declaration of the main cleartext listener (default 8080, :37) when redeclareCleartextEndpoint is left at its default (:124-126), because an explicit Listen call otherwise overrides the container's ASPNETCORE_HTTP_PORTS binding entirely. The reason for the second listener is operational and documented at the top of the file (:8-22): ACA httpGet probes speak HTTP/1.1, which an Http2-only endpoint rejects with GOAWAY HTTP_1_1_REQUIRED, which is why those probes used to be TCP-only and never consulted the real dependency-aware health checks. A dedicated Http1 listener on a port that is never published through ingress gives the platform a real target, since MapDefaultEndpoints() maps the health routes on every listener. ADC's three REST services pass HttpProtocols.Http2 (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:85, MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:81, MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:68) and the SignalR-hosting Notification service passes Http1AndHttp2 with redeclareCleartextEndpoint: false because its endpoints come from configuration (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:71). This is ADR-012 made concrete, and it tags [Rubric §17, DevOps & Deployment].

Health checks: liveness, readiness, and the "optional" tag

MapDefaultEndpoints() (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:411) exposes the three-probe surface the platform reads: /health (every check, for humans and dashboards, :413), /alive (only checks tagged live, :417-420), and /health/ready (:433-436). None of those three paths is a literal at the mapping site: they come from HealthEndpointPaths (Level 0, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/HealthEndpointPaths.cs:8), a constants holder carrying Health (:11), Alive (:14) and Ready (:17) plus one predicate, IsProbePath (:29), which answers case-insensitively for /alive, /health, and anything below /health/, so a sub-route a host adds later is covered without a second edit (:30-33). The type exists to keep the mapping, the probe telemetry filters and the gateway's downstream probe from drifting apart (:3-7). Every ADC host maps it (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:376, MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:314, MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:312, MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:244, MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:136, MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:122). The tag vocabulary is a named type rather than string literals, HealthCheckTags (Level 0, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/HealthCheckTags.cs:6), with three constants: Live (:12), Ready (:18), and Optional (:32). Read the readiness predicate carefully, because it encodes a hard-won operational rule: it excludes both live and optional (Extensions.cs:435). Excluding live keeps a downstream SQL outage from restarting the container. Excluding optional is the subtler half, argued at length in both the tag's own doc comment (HealthCheckTags.cs:20-31) and the endpoint comment (Extensions.cs:422-432): a dependency the app degrades gracefully without (a distributed cache behind an in-memory fallback, a broker behind a retrying outbox) must not gate readiness, because making it readiness-fatal converts a partial degradation into a total outage when every replica goes unready at once. Those checks still surface on /health, so the degradation stays visible without being self-inflicted.

AddDefaultHealthChecks() (:276) registers the baseline self check tagged Live (:279), and AddInfrastructureHealthChecks(requireDatabase) (:308) adds the dependency probes. The relational half is engine-agnostic and multi-source: AddDatabaseHealthChecks (:498) asks RelationalSources (:541) for every distinct database the host declares on each engine, reading both the top-level ConnectionStrings section and every named DataSources entry (:547-559) and deduplicating by connection string (:562-570), then registers one untagged SQL Server or SQLite check per database (:513-521). Untagged means they do gate readiness: readiness for a host that owns several databases means every database it serves from is reachable. The requireDatabase asymmetry is deliberate and documented (:290-302): a host that cannot resolve its own database is misconfigured, so no relational engine at all throws at startup (:506-511), the same fail-fast posture as ADR-070; Redis and RabbitMQ are optional per host and are added only when their connection string is present, tagged Optional (:314-331, :333-342), so the same binary runs unchanged in an integration-test environment where those containers are absent. All four ADC services pass requireDatabase: true (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:164, MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:146, MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:144, MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:129).

The Redis branch is worth reading as a case study in why the tag rules exist, because it was learned in production. RedisCachingExtensions (Level 0, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Caching/RedisCachingExtensions.cs:29) is the framework-owned way to wire a host to Redis: AddRedisCaching(connectionName) (:57) registers the distributed cache and the IConnectionMultiplexer client with DisableHealthChecks = true on both (:64-65), and AddRedisOutputCaching(connectionName) (:91) backs the ASP.NET Core output cache with the same instance so tag eviction crosses replicas (:99). Both are no-ops when the connection string is absent (:59-62, :93-97). The reason for the wrapper is stated in its own doc comment (:12-27): Aspire's Redis integrations register the AspNetCore.HealthChecks.Redis check with no tags, an untagged check silently gates readiness, and that check issues CLUSTER INFO against anything StackExchange.Redis 3.x detects as clustered, which is how it sees Azure Managed Redis (Enterprise tier). The server refuses that command outside admin mode, so every probe threw against a healthy cache, every replica reported not ready, and the platform stopped routing traffic the applications could serve. The fix is structural rather than a tag patch on someone else's registration: the Aspire checks are switched off at the source and Common contributes RedisPingHealthCheck (Level 0, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Health/RedisPingHealthCheck.cs:26), which issues nothing but PING (:52), resolves the multiplexer from DI and only lazily builds its own behind a semaphore when the host registered no client (:47-48,90-101), reports rather than throws on any failure (:64-72), and is registered once as a singleton tagged Optional (Extensions.cs:323-330). ADC's four services call AddRedisCaching() (.../Conference.Service/Program.cs:130, .../Identity.Service/Program.cs:123, .../Engagement.Service/Program.cs:102, .../Notification.Service/Program.cs:105), and Conference additionally calls AddRedisOutputCaching() (:140). This is [Rubric §17, DevOps & Deployment] (the probe contract the deployment platform consumes) and [Rubric §13, Observability & Operability] made concrete; the readiness revision is recorded in ADR-025.

Telemetry: what gets exported, and what it costs

ConfigureOpenTelemetry() (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:128) wires logging with formatted messages and scopes (:130-134), metrics, and tracing. It adds seven framework meters, MMCA.Common.Outbox, MMCA.Common.Cqrs, MMCA.Common.Idempotency, MMCA.Common.Scheduler, MMCA.Common.Broker, MMCA.Common.OutputCache, and MMCA.Common.BestEffort (:199-205), plus the MMCA.Common.Outbox activity source (:210), all by literal name, because the Aspire package has no reference to the assemblies that define them (its only project reference is Shared). Several of those meters are inert until a host opts into the feature behind them, the scheduler in a host that never enables Scheduler:Enabled and the broker meter in a host that stays on the in-process bus, which the in-code comment states explicitly (:190-198). Four cost levers hang off this method, and all four fail safe. First, OutboxPollFilterProcessor (Level 9, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Telemetry/OutboxPollFilterProcessor.cs:15) is registered via .AddProcessor(...) (Extensions.cs:246): a BaseProcessor<Activity> whose OnEnd (OutboxPollFilterProcessor.cs:27) walks each ending span's in-process parent chain (:37), matches on both operation name and source name so an unrelated span called OutboxPoll is not swept up (:39-40, the two literals pinned as private constants at :23-24), and clears ActivityTraceFlags.Recorded (:45) so the batch exporters skip it. It must be registered before the exporters so its OnEnd runs first, and it returns rather than throws on a null activity (:29-33), because a telemetry callback must never take the process down. Real per-message OutboxProcess work restores an explicit parent context and is never a poll descendant, so genuine outbox telemetry survives (the poll machinery, OutboxProcessor, IOutboxSignal, and OutboxMessage, lives in the events and outbox group, ADR-003). Second, TryGetTraceSampleRatio (Extensions.cs:448) reads an optional Telemetry:TracesSampleRatio and, only when it parses inside the open interval (0,1) (:452-454), installs a ParentBasedSampler(TraceIdRatioBasedSampler(...)) (:261-262) for head-based sampling; a typo, a blank, or an out-of-range value falls back to sampling everything, so a mistake can never silently blackhole all telemetry, and ParentBased keeps a sampled-in trace intact across service boundaries. Third, IsInstrumentationDisabled (:471-472) backs two opt-in kill switches, Telemetry:DisableHttpClientMetrics (:148) and Telemetry:DisableRuntimeMetrics (:175), which drop the two highest-volume metric families on a low-traffic multi-service deployment. Note how they drop them: skipping the Add*Instrumentation call is not enough, because a deployed host also calls UseAzureMonitor() and the Azure Monitor distro adds the System.Net.Http and System.Runtime meters itself, so each branch installs a MetricStreamConfiguration.Drop View over the whole meter instead (:160-164, :180-183, reasoning at :150-159 and :177-179). A View applies to the MeterProvider regardless of which component added the meter, which is what makes the toggle authoritative rather than advisory. Unset keeps everything, so a host that does not opt in sees no behavior change. Fourth, and unlike the other three on by default, Telemetry:FilterProbeTelemetry (the key is a named constant, :35, read by IsProbeTelemetryFilterEnabled at :483-484) keeps health-probe traffic out of trace export: absent, blank or unparseable all mean filter, and only an explicit boolean false turns it off for a host debugging its own probes (:474-484). It takes two passes, because a probe request and its dependency children are sampled independently. ProbeTelemetryFilter (Level 9, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Telemetry/ProbeTelemetryFilter.cs:27) supplies the two instrumentation predicates, wired onto the default-named ASP.NET Core and HttpClient options (Extensions.cs:230-233) so they also govern the instrumentation the Azure Monitor distro adds, which is what makes them authoritative without a View (:227-229). ShouldCollectRequest (ProbeTelemetryFilter.cs:40) refuses an inbound probe request and on its way out stamps an mmca.probe tag (:33, :51) on the current server activity; ShouldCollectOutgoing (:62) refuses the outbound probe calls that have no inbound parent to inherit from, the gateway's DownstreamServiceHealthCheck calls to each backend's /alive and YARP's active health checks, both driven by background timers (:20-25). The marker exists because refusing the request makes the instrumentation return before it writes url.path, leaving descendants with no way to recognize their probe ancestor (:12-19). ProbeTelemetryFilterProcessor (Level 10, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Telemetry/ProbeTelemetryFilterProcessor.cs:20) is the second pass, registered only when the knob is on and, like the outbox one, ahead of the exporters (Extensions.cs:248-254): OnStart (:29) and OnEnd (:40) run the same walk up the in-process parent chain (:52) and, on a probe ancestor, clear both ActivityTraceFlags.Recorded and IsAllDataRequested (:59-60), so the database check's SELECT 1, the Redis PING and the gateway's /alive call never reach an exporter. A parent counts as a probe when it carries the marker tag (:68) or is a server span whose url.path, http.route, or route-suffixed display name is a probe path (:76-79); server kind only, so a normal request never loses its whole subtree because one dependency happened to be a probe (:73-75). Both passes return rather than throw on a null activity (:44-48), and running at end as well as at start is deliberate, since a client span is started before its instrumentation has written any attribute (:32-39). Metrics are untouched by design, so probe traffic stays on dashboards while leaving the trace bill (:14-17); the volume is concrete, probe requests accounted for every AppRequests row in both production workspaces and their children for most of the AppDependencies volume (ProbeTelemetryFilter.cs:8-11, ProbeTelemetryFilterProcessor.cs:10-12). Exporters are equally conditional: AddOpenTelemetryExporters (:361) enables OTLP when OTEL_EXPORTER_OTLP_ENDPOINT is present (the local Aspire dashboard, :363-369) and Azure Monitor when APPLICATIONINSIGHTS_CONNECTION_STRING is present (:371-377), and both can be active at once. The whole shape is ADR-041: instrument where auto-instrumentation is blind, then expose cost knobs whose defaults never go dark. [Rubric §13, Observability & Operability] and [Rubric §31, Cost & FinOps].

One logging detail belongs to this boundary rather than to the telemetry pipeline itself. SerilogHostExtensions (Level 0, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Logging/SerilogHostExtensions.cs:27) carries the bootstrap every service host used to repeat by hand: AddCommonSerilog(logFilePath, configure) (:48) builds the framework configuration (CreateLoggerConfiguration at :98: minimum level Debug in Development and Information elsewhere via ResolveMinimumLevel at :76, EF Core and ASP.NET Core overridden to Warning at :108-109, console always at :110, a daily rolling file sink everywhere except Production via ShouldWriteFileSink at :87), publishes it as the global Log.Logger, and registers it with builder.Logging.AddSerilog(...) (:54-55). That last line is the load-bearing part, stated in the type's doc comment (:16-21): UseSerilog() replaces the whole ILoggerFactory and silently bypasses every other provider, including the OpenTelemetry to Azure Monitor one AddServiceDefaults() wires, so a host that calls it publishes no application log line to App Insights at all. AddSerilog adds Serilog alongside the others instead. CreateBootstrapLoggerFactory() (:67) covers the startup diagnostics a host needs before the DI container exists, module discovery above all. All four ADC services call AddCommonSerilog immediately before AddServiceDefaults() (.../Conference.Service/Program.cs:99-100, .../Identity.Service/Program.cs:96-97, .../Engagement.Service/Program.cs:82-83, .../Notification.Service/Program.cs:85-86).

Warm-up: defeating ACA cold-start

The warm-up subsystem exists for one concrete failure mode: the "first request fails, second succeeds" pattern on a CPU-throttled idle ACA replica, where lazy initialization (OIDC discovery fetch, connection-pool establishment, JIT) stretches past a client timeout. AddWarmupReadiness() (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:108, folded into AddServiceDefaults at :50) registers four cooperating pieces. IWarmupTask (Level 0, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Warmup/IWarmupTask.cs:9) is the unit of startup work: a Name for logs (:12) plus ExecuteAsync (:15). WarmupHostedService (Level 1, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Warmup/WarmupHostedService.cs:28) is a BackgroundService that runs all registered tasks in parallel exactly once (:53) and then opens the gate in a finally block (:56-60), so even when a task throws, the replica is never wedged permanently out of rotation. Each task additionally runs under a 120 second ceiling applied with WaitAsync over an injectable TimeProvider (:42,44-45,69): a task that neither completes nor throws used to leave Task.WhenAll pending forever and the gate closed with it, and the timeout turns that case into the same log-and-continue path as a failure (:77-82), which closes the one gap ADR-025 originally recorded as open. A per-task catch logs a genuine failure at Warning and lets the others proceed (:84-88), while a real host-shutdown cancellation is rethrown (:73-76). WarmupReadinessGate (Level 0, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Warmup/WarmupReadinessGate.cs:10) is a thread-safe one-shot flag (Volatile.Read at :15, Interlocked.Exchange at :18, over an int), consumed by WarmupReadinessHealthCheck (Level 1, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Warmup/WarmupReadinessHealthCheck.cs:9), which is registered tagged HealthCheckTags.Ready (Extensions.cs:115-116) and reports Unhealthy until the gate opens (WarmupReadinessHealthCheck.cs:14-16). That is exactly what makes /health/ready hold ingress traffic off a still-warming replica.

Two task shapes ship with the framework. OpenIdConnectMetadataWarmupTask (Level 1, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Warmup/OpenIdConnectMetadataWarmupTask.cs:21) is registered unconditionally by AddWarmupReadiness (Extensions.cs:113): it reads Authentication:JwtBearer:Authority (:30, the same key the AppHost's WithJwksDiscovery injects), returns quietly when it is unset (:31-34), warns and returns when it is not a valid absolute URI (:36-43), and otherwise GETs {authority}/.well-known/openid-configuration through the shared IHttpClientFactory (:45-46) to warm DNS, TCP, TLS, and the connection pool. Its remarks state the honest caveat (:14-20): the JwtBearer middleware caches discovery state separately and still performs its own first fetch, but now over a warm connection. SelfHttpWarmupTaskBase (Level 1, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Warmup/SelfHttpWarmupTaskBase.cs:28) is the abstract base for the deeper variant: after awaiting ApplicationStarted (:102, implemented at :191, because the warm-up runner starts before Kestrel is listening) it replays a subclass-supplied list of WarmupPaths (:59) against this host's own bound cleartext port, resolved from the server's addresses feature with ASPNETCORE_URLS and port 8080 as fallbacks (ResolveWarmupPort, :157, default at :39). Three virtual members are the extension points that make it usable on every host shape: RequestVersion and RequestVersionPolicy default to HTTP/2 pinned exactly (:70,77), because an Http2-only cleartext endpoint rejects a silently downgraded HTTP/1.1 request, and RequireSuccessStatusCode (:90) can be turned off so an intentional 401 on an [Authorize] route still counts (the refusal traverses Kestrel, routing, and authentication, which is the JIT cost being paid down). The whole task is skipped under the Testing environment (:46,95-98), where WebApplicationFactory's in-memory server never opens a socket. Services register their own tasks via AddWarmupTask<TTask>() (Extensions.cs:390): ADC wires SelfHttpOutputCacheWarmupTask in Conference (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:257) and a SelfHttpWarmupTask in Engagement and Identity (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:158, MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:169). This whole subsystem is the decision recorded in ADR-025: warm-up is wired into AddServiceDefaults so every host gets it, the gate opens even when a task fails or times out (availability over strict warmth), and the missed work is re-paid as a lazy retry on the first real request through the resilience pipeline. Tag-wise it leans on [Rubric §29, Resilience & Business Continuity] and [Rubric §17, DevOps & Deployment].

Configuration secrets: the vault as one more configuration source

Connection strings, RSA signing keys and OAuth client secrets have to reach the process somehow, and KeyVaultConfigurationExtensions (Level 0, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Configuration/KeyVaultConfigurationExtensions.cs:21) is the framework's answer: AddCommonKeyVaultConfiguration() (:78) layers an Azure Key Vault over the host configuration so every secret is readable through IConfiguration exactly like any other setting, and overrides the sources added before it (:109). Two keys drive it. KeyVault:Uri is the gate (:80): absent or whitespace and the method does nothing at all (:85-88), so a developer machine, a test host, and the Helpdesk seed keep the sources they already have and take no Azure dependency at startup. KeyVault:ReloadIntervalMinutes is optional (:96), and a non-positive or unparseable value throws rather than falling back to "never reload" (:97-107), because a silently ignored interval leaves the host serving startup values forever and the operator only finds out when a rotated credential fails to take effect. Authentication is DefaultAzureCredential (:109), so a deployed host uses its managed identity and a developer falls back to the Azure CLI or Visual Studio sign-in; the secret naming convention is the double dash, which the provider maps onto the configuration separator (ConnectionStrings--Default arrives as ConnectionStrings:Default). Two design notes are worth carrying forward. First, this is deliberately not called from AddServiceDefaults() (:56-62), for the same reason AddCommonDataProtection is not: service defaults run in every host, and an unconditional Azure dependency at startup would be a liability on a laptop. Second, builder.Configuration is a ConfigurationManager, which builds and loads each source as it is added (:63-69), so the vault is read synchronously at this point in startup and the call belongs early, before the settings binding that reads those values. All four ADC services and the Blazor UI host opt in immediately after AddServiceDefaults() (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:110, MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:105, MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:92, MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:95, MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:41). [Rubric §11, Security] (secrets out of the repository and out of the process environment, rotatable without a redeploy) and [Rubric §17, DevOps & Deployment].

Security headers, CORS, and the shared key ring at the host edge

The next boundary in this group hardens the request and response edge. The shared response-header middleware is defined entirely in MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Security/SecurityHeaders.cs. SecurityHeadersSettings (Level 0, :19) holds the strongly-typed values bound from the "SecurityHeaders" section (:22): FrameOptions defaulting to DENY (:25), ReferrerPolicy (:28), PermissionsPolicy (:31), HSTS opt-out and value (:34,37), an enforce-versus-report-only switch (:58), and a complete hardened CSP baseline (:53-55) that ships script-src 'self' 'wasm-unsafe-eval' and style-src 'self' 'unsafe-inline' at exactly the strength Blazor and MudBlazor require, so an HTML host that never registers a provider still gets a functional policy rather than one silently missing both directives, while the JSON, WebSocket and static responses of API and gateway hosts are unaffected (:39-52). ICspPolicyProvider (Level 1, :72) is the per-host CSP extension point, with StaticCspPolicyProvider (Level 2, :79) as the default that resolves the configured policy once in its constructor (:83-90) and returns it for every request (:92); a Blazor host registers its own dynamic implementation before calling AddCommonSecurityHeaders, which is exactly what ADC's UI host does with the shared BlazorCspPolicyProvider behind AddCommonBlazorCsp() (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:102-103, also turning HSTS off there because the client-facing Gateway emits it). CspPolicy (Level 0, :64) is the resolved (Value, Enforce) record that decides between Content-Security-Policy and Content-Security-Policy-Report-Only, keeping a policy and its enforcement mode inseparable. SecurityHeadersMiddleware (Level 2, :133) stamps X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and Permissions-Policy on every response (:167-170), adds HSTS only outside Development (decided once in the constructor at :158, applied at :172-175), and emits whichever CSP header the provider's Enforce flag selects (:191-198). Between those two steps sits the nonce path: a policy carrying the literal {nonce} token gets a fresh 16-byte value per request (:136,139,184-189), stashed raw in HttpContext.Items before the rest of the pipeline runs so the page render can read it, and substituted into the header as the quoted 'nonce-<value>' source-list form. CspNonce (Level 0, :110) is the tiny public surface for that: the ItemKey constant (:113) and a Get(context) helper (:120-124) a layout calls to stamp the value onto its own script tags. It is the supported path off 'unsafe-inline', and a policy with no placeholder generates nothing (:98-108). SecurityHeadersExtensions (Level 3, :210) supplies the two registration halves: AddCommonSecurityHeaders (:220), which binds the config section (:226-230) and registers the default provider through TryAddSingleton so a pre-registered custom provider wins (:237), and UseCommonSecurityHeaders (:245). The stated point is to centralize what each client-facing host previously hand-rolled, eliminating drift; this is ADR-023.

Two siblings complete this boundary. GatewayCorsExtensions (Level 0, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/GatewayCorsExtensions.cs:16) exposes AddCommonGatewayCors (:24), which registers the reverse proxy's default CORS policy: allow-any origin in Development (:34-41) but in every other environment restricting origins to Cors:AllowedOrigins while allowing any header and method plus credentials (:44-52), because the CORS spec forbids combining allow-any-origin with credentials. It is the gateway half of the two-tier posture in ADR-082, deliberately looser than MMCA.Common.API's per-service allow-listed AddCommonCors, because a proxy must pass arbitrary client headers through; ADC's Gateway wires it alongside the header middleware (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:84,89,137). DataProtectionExtensions (Level 0, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/DataProtection/DataProtectionExtensions.cs:19) fixes the multi-replica half of the same story: the framework default keeps the ASP.NET Core key ring in memory, so on a scaled-out host an auth cookie or antiforgery token minted by replica A cannot be decrypted by replica B, and the user sees random sign-outs that follow the load balancer rather than any pattern. AddCommonDataProtection() (:52) persists the key ring to a blob whose URI comes from DataProtection:BlobStorageUri, authenticated with DefaultAzureCredential (:54,68,71-72), sets an application discriminator from DataProtection:ApplicationName or the host name (:64), and optionally encrypts the ring at rest with a Key Vault key (:81-85). The two gates are deliberately independent (:74-80): blob persistence is what makes cookies portable and has to work without the Key Vault Crypto User role, which is granted out of band and can lag a deployment, so coupling them would turn an optional hardening gap into a total authentication outage. Absent config, the method returns immediately (:59-61), so a developer machine, a test host, and the Helpdesk seed take no Azure dependency at startup. ADC calls it on the two hosts that mint cookies and antiforgery tokens, Identity and the Blazor UI (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:112, MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:43); this is ADR-069. Relevant tags across this boundary: [Rubric §11, Security] and [Rubric §26, Front-End Security] (defense-in-depth headers, CSP with a nonce path, and a key ring that survives scale-out), and [Rubric §13, Observability & Operability] (one shared middleware, one shared CORS policy, and one shared key-ring registration instead of N hand-rolled copies).

The gateway edge kit: correlation, rate limiting, downstream readiness

A YARP gateway is the one process every client request passes through, and it is also the one host that has no application container: no DbContext, no module loader, no ICorrelationContext. That is why the three edge behaviors live in their own namespace, MMCA.Common.Aspire.Gateway, with no dependency beyond ASP.NET Core itself. The decision, including the three responsibilities the edge explicitly declines, is ADR-088.

GatewayCorrelationMiddleware (Level 9, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Gateway/GatewayCorrelationMiddleware.cs:27) is the twin of MMCA.Common.API's CorrelationIdMiddleware and exists precisely because that one cannot run here: it writes onto a scoped ICorrelationContext that only a host with the Common application services registered owns. This one takes RequestDelegate and nothing else (:27), which is what makes it safe to drop into a bare proxy. InvokeAsync (:42) reads X-Correlation-ID (the header name is a public constant deliberately duplicating the API package's literal, :34, since the two packages share no reference) and, when the caller sent none, mints one from Activity.Current?.TraceId with TraceIdentifier as the fallback, then writes it onto the request headers (:53-54). Writing the request side is the whole point: the proxied request carries the ID downstream, so the service-side middleware adopts it instead of minting a second one. The response echo is registered through HttpResponse.OnStarting (:58) so it survives a response whose headers the forwarder writes. GatewayCorrelationExtensions (Level 10, same file :73) is the one-line UseGatewayCorrelation() pipeline call (:82).

The edge limiter is two types. GatewayRateLimitingSettings (Level 0, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Gateway/GatewayRateLimitingSettings.cs:47) binds the optional GatewayRateLimiting section (:50): PermitLimit 120 (:59), WindowSeconds 60 (:63), GlobalConcurrencyLimit 200 (:73), extra BypassPathPrefixes (:82), and the synthetic-traffic pair, a header name (:91) and the shared secret that arms it (:115). Three properties of the design are load-bearing and stated in its remarks (:11-34): the limiters count in this replica's memory, so the effective allowance multiplies by replica count, a trade taken because an edge limiter must answer in microseconds and a shared counter would put a network round trip in front of every request; partitioning is by Connection.RemoteIpAddress, so forwarded headers must be applied before the limiter or every request is attributed to the ingress; and a scheduled capacity proof drives its whole load from one runner IP, which the per-IP window cannot tell from a flood, so a run presenting the configured header value takes the same no-limiter partition a bypassed path takes. The secret is off unless configured, must be at least 32 characters, and belongs in a secret store rather than in appsettings (:98-114). GatewayRateLimitingExtensions (Level 1, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Gateway/GatewayRateLimitingExtensions.cs:39) composes them: IsBypassed (:67) matches whole path segments case-insensitively against the always-exempt /health, /alive, /.well-known (:55) plus the host's configured prefixes, IsSyntheticTraffic (:94) requires exactly one header value and compares it with CryptographicOperations.FixedTimeEquals (:114), ClientIpPartition (:124) returns a no-limiter partition for a bypassed or proven-synthetic request (:133) or an unresolvable IP (fail open rather than collapsing every unattributable request into one bucket, :141) and otherwise a fixed window per IP with no queue (:144), and ConcurrencyPartition (:162) is one process-wide bucket that also rejects immediately rather than queueing (:171), because the failure it guards against is a slow downstream backing requests up until the edge runs out of threads. AddGatewayRateLimiting has two overloads: the configuration one binds with ValidateDataAnnotations().ValidateOnStart() (:188-196), and the settings one runs Validator.ValidateObject at registration (:218) because the limiter closes over an eagerly bound copy rather than resolving IOptions per request, which honors ADR-070 on both paths at the cost of no hot reload. The two limiters are chained (:227), so a request must satisfy both, and rejection is 429 (:222). UseGatewayRateLimiting() (:244) is the pipeline half. Note the deliberate inversion of ADR-019: the service-side global limiter exempts anonymous callers, and the edge does not, because the output cache that justified that exemption sits behind the proxy. [Rubric §11, Security] and [Rubric §12, Performance & Scalability].

Readiness is the third piece, and it is the one that learned the most. GatewayHealthCheckExtensions (Level 2, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Gateway/GatewayHealthCheckExtensions.cs:70) exposes AddGatewayDownstreamHealthChecks in two overloads (:130, :148), which for each Aspire service name registers a named HttpClient whose base address is the service-discovery form http://{name} (:189-201, resolved by the AddServiceDiscovery that AddServiceDefaults already wired, so nothing hard-codes a host or port) and a downstream-{name} health check over it (:203-212). Two choices carry the design (:113-128): the checks are tagged Ready, never Live, because restarting the gateway fixes nothing about a downstream outage; and their failure status is Unhealthy rather than Degraded, because /health/ready treats Degraded as passing, so a Degraded check would report a problem while still taking traffic the gateway cannot serve. The probe budget is 2 seconds (:84), short because it runs per downstream on every readiness poll. DownstreamServiceHealthCheck (Level 1, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Gateway/DownstreamServiceHealthCheck.cs:39) is the hand-rolled check itself, a GET and a status comparison (:143-175), with a narrow catch list plus a cancellation guard so host shutdown is never reported as a dependency fault (:75-87). It probes /alive, not /health/ready, taking the path from the shared HealthEndpointPaths.Alive constant rather than a literal of its own (:46), and the reason is in its remarks (:17-21): the gateway needs to know whether the service exists and is reachable, and probing readiness would make every rolling downstream deployment register as a gateway failure. The protocol question is settled per downstream rather than configured: DownstreamProbeVersion (Level 0, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Gateway/GatewayHealthCheckExtensions.cs:10) offers Auto, Http2 and Http11 (:18,25,32), and GatewayDownstreamHealthCheckOptions (Level 1, same file :40) defaults to Auto (:59) because neither fixed answer is right for every head: an h2c-only endpoint refuses HTTP/1.1, and a mixed Http1AndHttp2 cleartext endpoint without ALPN answers HTTP_1_1_REQUIRED to HTTP/2 forever (:42-57). Under Auto the check asks for HTTP/2 first and, on a protocol refusal only (IsProtocolRefusal at :184 matching VersionNegotiationError or HttpProtocolError), retries once as HTTP/1.1 inside the same poll (:98-131), then latches the version that answered with Interlocked.CompareExchange (:192) so the fallback is a one-time cost per downstream rather than a per-poll one. Nothing latches on a connectivity fault, because a transient outage says nothing about which protocol the endpoint speaks. GatewayDownstreamRegistry (Level 0, same file as the extensions, :225) is the small ledger that makes the registration idempotent, the same shape as the H2c one above: GetOrAdd (:257) attaches one instance to the collection and TryClaim (:240) skips a name an earlier call already registered.

The YARP building blocks: cluster profiles, ejection, trace headers, route policies

The route table itself is configuration, not code (ADR-089), and MMCA.Common.Gateway deliberately does not load it: LoadFromConfig (or any other IProxyConfigProvider) stays the host's call, and this package only adds behavior on top of whatever source the host chose (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewayReverseProxyExtensions.cs:15-20). GatewaySettings (Level 2, MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewaySettings.cs:12) is its whole configuration surface, bound from the MmcaGateway section (:15), and it duplicates nothing from the YARP ReverseProxy section: only the cross-cutting defaults a gateway would otherwise copy into every cluster by hand. GatewayReverseProxyExtensions (Level 4, :26) is the single entry point, AddMmcaGateway, in a configuration overload that binds with ValidateDataAnnotations().ValidateOnStart() (:47-59) and a settings overload for a host that composes in code (:68-79, supplying an explicit IOptions<> instance because the settings are init-only and the filters close over them for the process lifetime). Both funnel into one private Wire (:86) that registers the named policies and attaches two config filters plus one transform provider (:88-93).

Four building blocks hang off that. First, GatewayClusterProfileConfigFilter (Level 3, MMCA.Common/Source/Hosting/MMCA.Common.Gateway/Configuration/GatewayClusterProfileConfigFilter.cs:25) resolves each cluster's forwarder request profile from three sources, per property rather than per block (Resolve at :59, the four most-specific-wins helpers at :82-122): the cluster's own HttpRequest, then GatewayClusterRequestProfile (Level 0, GatewaySettings.cs:52) under ClusterRequestOverrides[clusterId], then ClusterRequestDefaults. That ordering is the point: a gateway fronting services that all speak h2c declares the shared activity timeout once instead of repeating an identical block per cluster, while the one cluster that genuinely differs still says so locally. The profile expresses the version pair as text (:58,64) so a mistyped value fails at startup with a message naming the cluster (ParseVersion at :76, ParseVersionPolicy at :96) instead of binding to a silent default, and IsSameAs (:131) returns an unchanged cluster by reference so YARP's config-change detection sees no churn on reload. Second, GatewayHealthCheckDefaultsConfigFilter (Level 3, MMCA.Common/Source/Hosting/MMCA.Common.Gateway/Configuration/GatewayHealthCheckDefaultsConfigFilter.cs:17) fills in destination health checks for clusters that declare none (:25-47), additively: an operator's explicit Passive or Active block is kept verbatim (:29-33). GatewayHealthCheckDefaults (Level 1, GatewaySettings.cs:119) splits into GatewayPassiveHealthCheckDefaults (Level 0, :132), on by default with the built-in TransportFailureRate policy and a 60 second reactivation period (:135-142) because passive checks watch the responses the gateway is already forwarding and cost no extra traffic, and GatewayActiveHealthCheckDefaults (Level 0, :150), off by default with ConsecutiveFailures, a 10 second interval, a 5 second timeout and a /alive path (:153-171) chosen for the same reason the gateway's own probes use it: readiness on a downstream flips during its own rolling deployment, and ejecting a destination for that is the gateway reacting to a healthy deployment as if it were an outage. Third, GatewayTraceHeaderTransformProvider (Level 3, MMCA.Common/Source/Hosting/MMCA.Common.Gateway/Transforms/GatewayTraceHeaderTransformProvider.cs:18) stamps the matched route id and target cluster id onto every proxied request from GatewayTraceHeaderSettings (Level 0, GatewaySettings.cs:178, defaults X-MMCA-Route and X-MMCA-Cluster at :181,184), capturing the ids once at build time (:51-54) and removing any inbound value before writing its own (:60-61), because a header a service trusts must be one only the gateway can set. Fourth, GatewayRoutePolicyExtensions (Level 3, MMCA.Common/Source/Hosting/MMCA.Common.Gateway/RateLimiting/GatewayRoutePolicyExtensions.cs:27) registers one named fixed-window policy per entry in RateLimiterPolicies (AddGatewayRoutePolicies at :39), the policies a YARP route references through its own RateLimiterPolicy property. Each GatewayRoutePolicySettings (Level 1, GatewaySettings.cs:212) states a GatewayRoutePolicyPartition (Level 0, :199, ClientIp or Global), a permit limit defaulting to 30, a 60 second window and a queue limit of zero (:215-230), and its PartitionKey (:243) returns null for an unresolvable IP so Partition (GatewayRoutePolicyExtensions.cs:81) hands back a no-limiter partition (:88-89) rather than one shared bucket. Values are validated at registration, not at the first throttled request (:49-52), again ADR-070. These policies are additive to any global limiter the host installs (:16-21): ASP.NET Core evaluates the global limiter and the route's named policy independently, so a request must satisfy both, and the two packages stay independent of each other.

The fifth type in this package is not about YARP configuration at all. ForwardedHeadersExtensions (Level 0, MMCA.Common/Source/Hosting/MMCA.Common.Gateway/ForwardedHeadersExtensions.cs:23) supplies UseCommonForwardedHeaders() (:36) and the shared CreateForwardedHeadersOptions() (:55) that applies X-Forwarded-For, -Proto and -Host with the known-proxy and known-network allow-lists cleared (:59-63). Clearing them is load-bearing: cloud reverse proxies front the app from internal IPs that are in neither default allow-list, so leaving the defaults in place makes the middleware ignore every forwarded header and report the ingress IP as the client. It lives here because a service host gets this step from UseCommonMiddlewarePipeline in MMCA.Common.API, and a gateway takes none of that package (:9-17); without it a gateway hand-rolls the same five lines, and getting them wrong is invisible until production, where an unforwarded gateway collapses the per-client-IP rate-limit partition into one shared window for every real user.

ADC's Gateway wires all of this in an order the comments call out as contractual (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:57-115 for registration, :124-158 for the pipeline): AddServiceDefaults() (:57), the edge limiter (:64), one call registering all four downstream checks on the Auto default (:78), shared security headers (:84) and CORS (:89), then AddReverseProxy().LoadFromConfig(...).AddMmcaGateway(...).AddServiceDiscoveryDestinationResolver() (:112-115). The pipeline is forwarded headers first so the limiter sees the real client IP (:124), then correlation (:129) so even a 429 from the limiter carries an ID, then security headers (:134), MapDefaultEndpoints (:136), CORS (:137), the limiter after CORS so a rejected preflight is still a CORS answer (:144), static files and the /privacy alias (:149-155), and finally MapReverseProxy() (:158) so a throttled request never reaches a backend. Its GatewayRateLimiting section adds /hubs to the bypass list (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:21-26) because a SignalR connection is long-lived and its negotiate and reconnect traffic must not be throttled, and its MmcaGateway section declares the shared 100 second forwarder activity timeout, the one-hour override for the notification-hub cluster, active destination probing at a 30 second interval, and the auth-tight per-route policy (appsettings.json:27-56). [Rubric §7, Microservices Readiness], [Rubric §13, Observability & Operability], and [Rubric §29, Resilience & Business Continuity].

How it all fits at runtime

Putting the pieces in sequence: the AppHost declares the graph, picks a broker once, and injects per-service env vars (WithSQLServerDataSource, WithSelectedBroker, WithJwksDiscovery, the three E2E helpers, and the gRPC WithReferences), attaching an /alive health check to every service resource in the shape its Kestrel profile can answer (WithH2cHealthCheck for the three h2c services, the stock probe for Notification) so each WaitFor edge gates on a real answer. Each service boots, sets its Kestrel protocol profile and optional Http1 probe listener with ConfigureEndpointsWithHealthProbe, calls AddCommonSerilog and then AddServiceDefaults() to opt into telemetry, health, resilience, and warm-up, then AddCommonKeyVaultConfiguration() so the settings binding that follows reads vault-backed secrets, AddRedisCaching() (and AddRedisOutputCaching() where the output cache must cross replicas), AddInfrastructureHealthChecks(requireDatabase: true) for its dependency probes, AddCommonDataProtection() where cookies must survive scale-out, and AddCommonSecurityHeaders plus UseCommonSecurityHeaders at the edge. The Gateway boots on the same AddServiceDefaults() and adds the edge kit (AddGatewayRateLimiting, AddGatewayDownstreamHealthChecks, AddCommonGatewayCors, UseCommonForwardedHeaders, UseGatewayCorrelation, UseGatewayRateLimiting) plus AddMmcaGateway over a route table it loads from configuration. Aspire withholds traffic via WaitFor and the /health/ready predicate until each replica is past its warm-up gate and its non-optional dependencies are healthy, and the gateway's own readiness additionally reflects whether it can reach the four services, on whichever HTTP version each of them latched. The warm-up tasks pre-pay the cold paths, bounded by the 120 second per-task ceiling. Once live, outbound calls ride the shared HttpResilienceDefaults pipeline over the idle-cost-tuned socket handler, east-west gRPC rides GrpcResilienceDefaults, integration events flow through the broker selected by the env vars behind the BrokerResilienceDefaults circuit breaker, YARP ejects a failing destination on the package's passive and active defaults, and telemetry streams to the OTLP endpoint or Azure Monitor minus the suppressed outbox-poll and health-probe spans, any dropped metric family, and any head-sampled trace fraction. The same env-var-and-abstraction contract is what lets a module run in-process or as an extracted service without code changes (ADR-007 and ADR-008): the AppHost decides topology by what it wires, and the service code stays transport-agnostic. The per-type sections that follow document each of these classes in ascending Level order; the module-registration and message-bus types they reference are in the module system (group 14) and events and outbox (group 04) chapters.

AppHostCompositionSmokeTests

MMCA.ADC.AppHost.SmokeTests · MMCA.ADC.AppHost.SmokeTests · MMCA.ADC/Tests/Integration/MMCA.ADC.AppHost.SmokeTests/AppHostCompositionSmokeTests.cs:23 · Level 0 · class (sealed)

  • What it is: A single-test tier that boots the real Aspire AppHost with DistributedApplicationTestingBuilder and polls the gateway's /health until it answers 200. It exists to prove the orchestration file still composes, not to assert any application behavior.
  • Depends on: Aspire.Hosting.Testing (DistributedApplicationTestingBuilder, app.CreateHttpClient), the generated Projects.MMCA_ADC_AppHost marker type, xUnit v3, and AwesomeAssertions. Transitively it depends on every resource the ADC AppHost declares, which is exactly the point.
  • Concept introduced, the composition-only smoke tier. [Rubric §14, Testability], [Rubric §17, DevOps], [Rubric §33, Developer Experience]. Every other ADC test tier boots services through WebApplicationFactory<Program> or Testcontainers, which means every other tier skips the orchestrator: the AppHost's resource graph is never executed. The class remark (MMCA.ADC/Tests/Integration/MMCA.ADC.AppHost.SmokeTests/AppHostCompositionSmokeTests.cs:12-17) names the failure class that leaves uncovered: a renamed resource, a reference that no longer resolves, or a WaitFor cycle is invisible to dotnet build and to every other tier, because nothing else runs the orchestration. §14 is about whether a claim can be falsified cheaply; this tier is the deliberate opposite of cheap (it pulls four container images before a single process starts), so it buys exactly one claim and stops.
  • Walkthrough:
    • Two const string fields name the resource to probe: GatewayResourceName = "gateway" and GatewayEndpointName = "http" (:26-27). These are the strings the AppHost declares, so a rename on either side breaks the test loudly, which is a feature here.
    • Three TimeSpan budgets follow. StartupBudget = 12 minutes (:33) covers building and starting the whole stack, generous because a cold agent pulls four images first (:30-32). ReadinessBudget = 8 minutes (:36) covers the gateway answering 200 once started, and PollInterval = 5 seconds (:39) is the gap between attempts. Splitting startup from readiness matters diagnostically: a timeout tells you which phase stalled.
    • AppHost_Starts_AndTheGatewayAnswersHealth (:42) creates a CancellationTokenSource from the startup budget (:44), builds the application model with DistributedApplicationTestingBuilder.CreateAsync<Projects.MMCA_ADC_AppHost>(startup.Token) (:46-47), then BuildAsync and StartAsync under the same token (:49-50). Projects.MMCA_ADC_AppHost is the source-generated marker Aspire emits for a referenced AppHost project, which is what lets a test project start the real orchestrator rather than a stand-in.
    • app.CreateHttpClient(GatewayResourceName, GatewayEndpointName) (:52) resolves the gateway's allocated endpoint from the running model, so the test never hardcodes a port. The assertion is a single status.Should().Be(HttpStatusCode.OK, ...) (:57-59) with a because string that restates the claim, then app.StopAsync(CancellationToken.None) (:61) tears the stack down with an uncancelled token so shutdown is not itself cut short by a spent budget.
    • PollUntilHealthyAsync(client, cancellationToken) (:73) is the readiness loop. It seeds last = HttpStatusCode.ServiceUnavailable (:77) so "the gateway never answered at all" is reported as a status rather than an exception, GETs the relative /health URI (:83-84), returns immediately on 200 (:86-89), and swallows HttpRequestException (:91-94) because a connection failure is the expected state while the gateway is still binding. The Task.Delay(PollInterval, cancellationToken) sits in its own try (:96-103) so budget expiry breaks the loop and returns the last observed status instead of surfacing an OperationCanceledException, which would report a cancellation where the useful signal is the last HTTP code seen.
  • Why it's built this way: One test on purpose (:12). The tier is probational and non-gating per ADR-098 (:18-21), and the CI job matches: apphost-smoke runs continue-on-error: true (MMCA.ADC/.github/workflows/cross-service-tests.yml:199-204), so a red reds the job for visibility without failing the run, and the workflow comment states that the cross-service-freshness deploy gate does not look at this job at all (:189-195). Its project is deliberately outside every .slnx / .slnf, so CI restores and builds it by explicit path (:216, :259) and runs it with --minimum-expected-tests 1 (:261-264), which turns a silent zero-discovery run into a failure.
  • Where it's used: Only from the nightly Cross-Service Integration Tests workflow (MMCA.ADC/.github/workflows/cross-service-tests.yml:1, scheduled at :27-31), in the apphost-smoke job (:198). It is not part of any local dotnet test --solution run.
  • Caveats / not-in-source: It needs a Docker daemon and cannot run in a headless sandbox. It asserts composition and one 200, so it says nothing about whether any service is functionally correct; that is the job of the integration and E2E tiers.

BrokerSelection

MMCA.ADC.AppHost · MMCA.ADC.AppHost · MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/BrokerSelection.cs:14 · Level 0 · class (internal static)

  • What it is: A one-method extension(IResourceBuilder<ProjectResource>) block whose WithSelectedBroker(attach) applies a broker attachment the AppHost decided once, up front, so every service's wiring chain reads the same single line regardless of which broker is running.
  • Depends on: Aspire.Hosting (IResourceBuilder<ProjectResource>) and, indirectly, the two WithBroker overloads on the Common AppHost-side Extensions (RabbitMQ) and its AddServiceBusEmulatorBroker / ServiceBusEmulatorResource pair. The env var it ultimately causes to be set is read by MessageBusSettings / MessageBusProvider in the consuming service.
  • Concept introduced, deferring a resource-type choice as a delegate. [Rubric §33, Developer Experience], [Rubric §7, Microservices Readiness], [Rubric §15, Best Practices & Code Quality]. The two WithBroker overloads take different resource types (IResourceBuilder<RabbitMQServerResource> versus the emulator resource), so the choice cannot be expressed as one variable handed to one call: C# has no common supertype to bind it to. The class doc comment (BrokerSelection.cs:7-12) states the reasoning: capturing the choice as a Func<IResourceBuilder<ProjectResource>, IResourceBuilder<ProjectResource>> keeps the decision in a single place and leaves every service chain identical, which is what stops a fifth service from quietly being wired to the wrong broker. §33 is the category that cares about environment parity, and this type is the mechanism that makes parity a one-variable switch instead of a four-site edit.
  • Walkthrough: The whole surface is WithSelectedBroker (BrokerSelection.cs:21-22) inside an extension(IResourceBuilder<ProjectResource> service) block (:16). It null-guards the delegate with ArgumentNullException.ThrowIfNull(attach) (:24) and returns attach(service) (:25). That is all it does: the type carries no state and makes no decision itself. The decision lives in the AppHost, which declares Func<IResourceBuilder<ProjectResource>, IResourceBuilder<ProjectResource>> withBroker (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:89) and assigns it from one environment-variable comparison: ADC_BROKER=servicebus (ordinal, case-insensitive, :91) selects builder.AddServiceBusEmulatorBroker(sqlServer) (:93-94), and anything else, including unset, selects builder.AddMessageBroker().WithLifetime(ContainerLifetime.Persistent) (:98-100).
  • Why it's built this way: The AppHost comment block above the selection (Program.cs:66-88) is the rationale, and it is worth reading in full. RabbitMQ is a different broker from the one production runs, so anything that only misbehaves on Azure Service Bus (entity-name limits, topic and subscription provisioning through the admin plane, scheduled redelivery pacing) is invisible in the inner loop until it reaches a deployed environment. Setting ADC_BROKER=servicebus swaps in the official Azure Service Bus emulator so the whole stack runs the same transport production runs, against a local container that reuses the already-declared sqlServer resource rather than starting a second engine (:75-84). Unset stays on RabbitMQ because the emulator costs a second container plus a warm-up, "which is not a price the everyday inner loop should pay", and the comment tags the decision rubric section 33 (environment parity) itself (:86-88). Nothing in any service's Program.cs changes either way: the emulator's quota and TTL constraints are handled inside MMCA.Common's AddBrokerMessaging, keyed off the UseDevelopmentEmulator=true marker in the connection string (:80-84). The transport-selection policy is ADR-066.
  • Where it's used: Once per ADC service in the AppHost chain: Identity (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:132), Conference (:161), Engagement (:204), and Notification (:232). It is internal and AppHost-local, so it has exactly one consumer assembly.
  • Caveats / not-in-source: When the broker env var is absent entirely (for example integration tests that boot a service through WebApplicationFactory<Program> with no AppHost at all), AddBrokerMessaging short-circuits to in-process mode and InProcessMessageBus keeps working (Program.cs:61-65); that fallback lives in the consuming service, not here.

DataProtectionExtensions

MMCA.Common.Aspire · MMCA.Common.Aspire · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/DataProtection/DataProtectionExtensions.cs:19 · Level 0 · class (static)

  • What it is: One opt-in registration call, AddCommonDataProtection(), that moves the ASP.NET Core DataProtection key ring out of process memory and into a single Azure Blob, so every replica of a scaled-out host shares one key ring, and optionally encrypts that key ring at rest with an Azure Key Vault key.
  • Depends on: Microsoft.AspNetCore.DataProtection plus its Azure Blob Storage and Key Vault key-ring providers (Azure.Extensions.AspNetCore.DataProtection.Blobs / .Keys), and Azure.Identity (DefaultAzureCredential). No first-party types: it is deliberately a thin adapter over the BCL and Azure APIs. Its opt-in shape is shared with KeyVaultConfigurationExtensions, which cites this class as its precedent (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Configuration/KeyVaultConfigurationExtensions.cs:57-58).
  • Concept introduced, the shared key ring. [Rubric §11, Security], [Rubric §29, Resilience & Business Continuity], [Rubric §17, DevOps]. DataProtection is the ASP.NET Core primitive behind auth cookies and antiforgery tokens: the framework encrypts them with a key ring it generates on first use. The default key ring lives in memory, which is correct for a single process and wrong the moment a host scales past one replica, because each replica mints its own keys and a cookie issued by replica A cannot be decrypted by replica B. The class doc comment (DataProtectionExtensions.cs:10-17) names the symptom precisely: random sign-outs and "The antiforgery token could not be decrypted" errors that follow no pattern "because they follow the load balancer". §29 is the category that cares: an intermittent, traffic-shaped auth failure is an availability defect that no single-replica test can reproduce. ADR-069 records the decision and the deliberately opt-in shape.
  • Walkthrough:
    • The whole surface is one method inside a generic extension<TBuilder>(TBuilder builder) where TBuilder : IHostApplicationBuilder block (:21-22), so it attaches to a web host and a worker host alike.
    • AddCommonDataProtection() (:52) reads DataProtection:BlobStorageUri (:54). Gate 1: when that key is absent or whitespace it returns the builder untouched (:59-62). The comment there (:56-58) is the rationale: a developer machine, a test host, and the Helpdesk seed all run single-process, where the in-memory default is right and an unconditional Azure dependency at startup would be a liability, not a feature.
    • Past the gate it resolves the application discriminator, DataProtection:ApplicationName falling back to builder.Environment.ApplicationName (:64-65). That string is what isolates one application's keys from another's when several share a blob container.
    • It constructs one DefaultAzureCredential (:68) and reuses it for both sinks, with the comment "One credential instance for both sinks so they share a single token cache" (:67). DefaultAzureCredential is what makes the same code path work in both places: a deployed host authenticates with its managed identity, a developer machine falls back to the Azure CLI or Visual Studio sign-in (doc comment :44-49, which also names the two role assignments needed, Storage Blob Data Contributor and Key Vault Crypto User).
    • The registration chain is AddDataProtection().SetApplicationName(applicationName).PersistKeysToAzureBlobStorage(new Uri(blobStorageUri), credential) (:70-72).
    • Gate 2 is separate on purpose: when DataProtection:KeyVaultKeyUri is set (:81-82), it adds ProtectKeysWithAzureKeyVault(...) (:84). The comment above it (:74-80) explains the split at length, and it is the teaching part of this type: blob persistence is the half that fixes cross-replica cookie and antiforgery decryption, and it "has to work on its own, WITHOUT the Key Vault Crypto User role, because that role assignment is granted out of band and can lag the deployment". Folding the two gates into one would turn a missing or delayed role assignment into a total authentication outage instead of an optional hardening gap.
  • Why it's built this way: Configuration-gated rather than always-on, so the framework never forces an Azure dependency on a host that does not need one; two independent gates rather than one, so hardening cannot take availability down with it; and a static class with an extension(T) block, which is the codebase's standard registration idiom (see primer, C# extension(T) types). Both decisions are recorded in ADR-069, which also notes that a host that simply never calls the method keeps the broken per-replica default.
  • Where it's used: The three hosts across both apps that mint cookies or antiforgery tokens: MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:43, MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:112 (Identity performs OAuth cookie cryptography and runs multi-replica with no session affinity, so a login started on one replica can fail on the other), and MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:82. Covered by DataProtectionExtensionsTests, which asserts both gates on a built service provider without any Azure credential (the blob client is constructed lazily by the repository, never at registration time).
  • Caveats / not-in-source: Nothing in this file validates that the configured URI points at a reachable blob: a wrong URI surfaces at first key-ring access, not at startup. Gate 2 needs a Key Vault Crypto User grant that is made out of band, which is exactly why it is a second, independent gate.

GatewayCorsExtensions

MMCA.Common.Aspire · MMCA.Common.Aspire · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/GatewayCorsExtensions.cs:16 · Level 0 · class (static)

  • What it is: Registers the shared default CORS policy for the reverse-proxy Gateway via a single AddCommonGatewayCors extension method.
  • Depends on: Microsoft.Extensions CORS / Configuration / Hosting (ASP.NET Core, BCL). No first-party types.
  • Concept introduced: [Rubric §11, Security], [Rubric §26, Front-End Security], [Rubric §9, API & Contract Design]. §11 and §26 assess a cross-origin policy that stays safe while still allowing credentials. The doc comment (GatewayCorsExtensions.cs:7-15) draws the distinction from a service host's CORS: a reverse-proxy gateway must pass arbitrary client headers through to the services it fronts, so, unlike MMCA.Common.API.AddCommonCors's allow-listed headers and methods, the production gateway policy allows any header and method while restricting origins to Cors:AllowedOrigins. That origin restriction is load-bearing: the CORS specification forbids combining AllowAnyOrigin() with AllowCredentials(), so to let cookies and Authorization headers flow, the policy must name explicit origins. ADR-082 is the record for exactly this two-tier posture (allow-listed service policies plus an any-header gateway policy). This is the browser-facing edge of the Gateway topology (ADR-008).
  • Walkthrough: AddCommonGatewayCors(configuration, environment) sits in an extension(IServiceCollection services) block (GatewayCorsExtensions.cs:18, method at :24). It null-guards the receiver and both arguments (:28-30), then registers a default policy inside services.AddCors (:32). In Development it allows any origin, header, and method (:37-40), with a scoped #pragma warning disable S5122 (:36, restored at :41) whose comment records that allow-any-origin is confined to Development. Otherwise it reads the Cors:AllowedOrigins string array, defaulting to [] (:45-47), and builds WithOrigins(origins).AllowAnyHeader().AllowAnyMethod().AllowCredentials() (:48-52). Because it is registered as the default policy, hosts pair it with a bare app.UseCors() (no named policy).
  • Why it's built this way: Credentialed cross-origin traffic requires enumerated origins, so the non-Development branch reads them from configuration rather than allowing everything; the any-header and any-method latitude reflects the gateway's pass-through role. One shared method keeps the ADC and Store gateways from drifting apart.
  • Where it's used: The YARP Gateway hosts: MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:89 and MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:77, each followed by a bare app.UseCors() (ADC Program.cs:137, Store Program.cs:162). Note the pipeline order in both hosts: UseCors() runs before GatewayRateLimitingExtensions' UseGatewayRateLimiting() (ADC :137, :144; Store :162, :170), and both hosts carry a comment saying why (ADC :139, Store :164): a rejected preflight is still a CORS answer, and a throttled cross-origin caller still receives the headers it needs in order to read the 429.
  • Caveats / not-in-source: Outside Development, an unset or empty Cors:AllowedOrigins yields an empty origin list, which blocks all cross-origin credentialed calls. That is fail-closed (a misconfiguration denies rather than permits), but the browser client cannot reach the gateway until origins are configured.

HealthCheckTags

MMCA.Common.Aspire · MMCA.Common.Aspire · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/HealthCheckTags.cs:6 · Level 0 · class (static)

  • What it is: The three tag-name constants (live, ready, optional) that the standard health endpoints mapped by MapDefaultEndpoints() filter on.
  • Depends on: Nothing. Three const string fields. Consumed by the Extensions registration and endpoint-mapping code, by RedisPingHealthCheck's registration, and by GatewayHealthCheckExtensions.
  • Concept introduced, health-check tags as a routing vocabulary. [Rubric §13, Observability & Operability], [Rubric §29, Resilience & Business Continuity], [Rubric §17, DevOps]. ASP.NET Core health checks each carry a set of string tags, and an endpoint maps a Predicate over those tags. That means the tag vocabulary is the operational contract between the app and the platform (Azure Container Apps or Kubernetes probes). Getting it wrong is not cosmetic: a liveness probe that fails restarts the container, and a readiness probe that fails removes the replica from traffic. Naming the three tags once, in a shared constants class, is what stops a consumer from tagging a check "Ready" or "live " and silently landing it on the wrong endpoint.
  • Walkthrough:
    • Live = "live" (HealthCheckTags.cs:12): liveness only. The check runs on /alive and is excluded from readiness. Its doc comment (:8-11) reserves it for self checks, "so an external dependency outage never restarts the container".
    • Ready = "ready" (HealthCheckTags.cs:18): the readiness gate. The check runs on /health/ready and holds traffic back until it passes. This is the tag the warm-up gate uses, and the tag the gateway's downstream checks use.
    • Optional = "optional" (HealthCheckTags.cs:32): a dependency the application degrades gracefully without. It is reported on /health but excluded from /health/ready. The long doc comment (:20-31) is the teaching part and is worth reading in full: a distributed cache sitting behind an in-memory fallback, or a broker behind a retrying outbox, can fail without the app losing the ability to serve. Gating readiness on such a dependency converts a partial degradation into a total outage, because every replica goes unready simultaneously and the platform stops routing traffic the app could still handle. A check is left untagged only when the app genuinely cannot serve correct responses without it, its own database being the standard example.
  • Why it's built this way: Constants rather than an enum, because the health-checks API takes string tags; a const also lets the values appear in collection expressions such as tags: [HealthCheckTags.Optional] with no conversion. The three-way split (live / ready / optional / untagged) encodes a deliberate blast-radius policy: liveness restarts, readiness withholds traffic, optional only reports. That policy is recorded in ADR-025, whose revision added the optional exclusion to /health/ready for exactly the total-outage reason above, and it is what ADR-088 applies to a dependency rather than to a startup task.
  • Where it's used: The service-defaults Extensions: the warm-up check is registered with [HealthCheckTags.Ready] (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:116), the "self" check with [HealthCheckTags.Live] (:238), and the conditional Redis and RabbitMQ checks with [HealthCheckTags.Optional] (:289, :301). The endpoint predicates read them back: /alive requires Live (:376-379) and /health/ready excludes both Live and Optional (:392-395). The relational database checks are deliberately left untagged (:462, :467), which is what makes them readiness-fatal. GatewayHealthCheckExtensions tags every downstream probe Ready. The tagging is asserted by InfrastructureHealthChecksTests and, for the Redis path specifically, by RedisReadinessSafetyTests.

HealthEndpointPaths

MMCA.Common.Aspire · MMCA.Common.Aspire · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/HealthEndpointPaths.cs:8 · Level 0 · class (static)

  • What it is: The three health-probe route constants (/health, /alive, /health/ready) plus one predicate, IsProbePath(string?), that answers whether a request path addresses any of them. Where HealthCheckTags names the tag vocabulary, this names the routes.
  • Depends on: Nothing first-party beyond a doc-comment reference to HealthCheckTags (HealthEndpointPaths.cs:13). Three const string fields and a static method over string.Equals / string.StartsWith.
  • Concept introduced, one owner for the probe route vocabulary. [Rubric §13, Observability & Operability], [Rubric §31, Cost / FinOps]. The probe routes are read in two very different places: the code that maps them (Extensions.MapDefaultEndpoints()) and the code that recognises them after the fact, namely the trace filters that keep probe traffic out of telemetry export (ProbeTelemetryFilter and ProbeTelemetryFilterProcessor) and the gateway's own downstream probe (DownstreamServiceHealthCheck). A literal "/alive" retyped on the recognising side is the classic silent break: the endpoint keeps working, the filter quietly stops matching, and the only symptom is a telemetry bill. Naming the routes once makes the mapping side and the matching side provably the same strings. The cost half of that is recorded in ADR-041.
  • Walkthrough:
    • Health = "/health" (HealthEndpointPaths.cs:11), Alive = "/alive" (:14), Ready = "/health/ready" (:17): the full report, the liveness probe, and the readiness probe respectively, each with a one-line doc comment naming which checks run there (:10, :13, :16).
    • HealthPrefix (:19) is private and composed from Health + "/", so it cannot drift from the constant it extends.
    • IsProbePath(path) (:29) returns false for null or empty, then matches Alive or Health exactly and anything under HealthPrefix (:30-33). The prefix arm is what makes /health/ready match without being listed, and it also covers any sub-route a host maps later. Every comparison is OrdinalIgnoreCase, which the doc comment (:21-28) ties to ASP.NET Core routing being case-insensitive.
  • Why it's built this way: Constants rather than an options type, because these routes are a platform contract (the Container Apps probe configuration and the availability web test point at them) and are not per-host configurable. IsProbePath lives beside them rather than inside either filter because both filters and the gateway probe need the same answer; putting the predicate next to the strings keeps the "what counts as a probe" definition in one file. The prefix rule is deliberately broader than the three constants, so adding a /health/live style route later needs no change on the recognising side.
  • Where it's used: Extensions.MapDefaultEndpoints() maps all three (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:413, :417, :433). ProbeTelemetryFilter calls IsProbePath for the inbound request path (Telemetry/ProbeTelemetryFilter.cs:42) and for an outgoing request URI (:63). ProbeTelemetryFilterProcessor calls it three ways on a finished activity, the url.path tag, the http.route tag, and the route parsed out of the display name (Telemetry/ProbeTelemetryFilterProcessor.cs:77-79). DownstreamServiceHealthCheck builds its relative probe URI from Alive (Gateway/DownstreamServiceHealthCheck.cs:46), so the gateway probes exactly the route its backends map.
  • Caveats / not-in-source: The predicate takes a path only; it never sees a query string, and the doc comment states that as the caller's obligation (:24-25). Nothing enforces that a host which maps extra health routes puts them under /health/, so a differently named probe route would evade the telemetry filters.

KeyVaultConfigurationExtensions

MMCA.Common.Aspire · MMCA.Common.Aspire · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Configuration/KeyVaultConfigurationExtensions.cs:21 · Level 0 · class (static)

  • What it is: One opt-in registration call, AddCommonKeyVaultConfiguration(), that layers an Azure Key Vault over the host configuration, so every secret in the vault is readable through IConfiguration exactly like any other setting, and overrides the sources added before it.
  • Depends on: Azure.Extensions.AspNetCore.Configuration.Secrets (AzureKeyVaultConfigurationOptions, AddAzureKeyVault), Azure.Identity (DefaultAzureCredential), and Microsoft.Extensions.Configuration / Microsoft.Extensions.Hosting (IHostApplicationBuilder). No first-party types: like DataProtectionExtensions, which it names as its precedent (KeyVaultConfigurationExtensions.cs:57-58), it is a thin adapter over the Azure APIs.
  • Concept introduced, configuration as an ordered stack with the vault on top. [Rubric §11, Security], [Rubric §17, DevOps & Deployment], [Rubric §17, DevOps]. §11 assesses where credentials live and how they rotate. The class doc comment (:9-20) frames the decision as a rejection of the two easy answers: a value checked into an appsettings file "is readable by everyone who can read the repository, and it stays readable in the history after it is removed", and a value injected as a deployment-time environment variable "is readable by anything that can read the process environment, and it is frozen until the next deployment, so rotating it means redeploying". Reading from a vault under the host's own managed identity avoids both and lets a rotation take effect on its own. Two mechanics make this land with no binding-side code change. First, IConfiguration is an ordered stack of sources and a later source wins, so appending the vault last means a vault secret transparently overrides the same key from a file. Second, because a secret name cannot contain a colon, the provider maps a double dash onto the configuration separator (doc comment :42-48): the secret ConnectionStrings--Default arrives as the key ConnectionStrings:Default, and Jwt--SigningKey as Jwt:SigningKey, so an existing settings class binds vault values untouched.
  • Walkthrough:
    • The whole surface is one method in a generic extension<TBuilder>(TBuilder builder) where TBuilder : IHostApplicationBuilder block (:23-24), so it attaches to a web host and a worker host alike.
    • AddCommonKeyVaultConfiguration() (:78) reads KeyVault:Uri (:80). The gate: absent or whitespace means "do nothing", returning the builder untouched (:85-87). The comment there (:82-84) gives the reason: a developer machine, a test host, and the Helpdesk seed read configuration from files and user secrets, where reaching for a vault at startup buys nothing and costs a hard Azure dependency on every run.
    • It then builds an AzureKeyVaultConfigurationOptions (:90) and reads the optional KeyVault:ReloadIntervalMinutes (:96). When that key is present it must parse as a positive whole number under the invariant culture, or the method throws InvalidOperationException naming the offending value (:102-103); a valid value becomes options.ReloadInterval = TimeSpan.FromMinutes(minutes) (:106). The comment above it (:92-95) is the teaching part: a misspelled interval fails loudly rather than falling back to "never reload", because silently ignoring it would leave the host serving the secret values it read at startup forever, and the operator would only find out when a rotated credential failed to take effect, which is exactly the wrong moment. Left unset entirely, the vault is read once at startup and a rotated secret only reaches the host on its next restart (doc comment :37-39).
    • The last statement is builder.Configuration.AddAzureKeyVault(new Uri(vaultUri), new DefaultAzureCredential(), options) (:109), then it returns the builder (:111). DefaultAzureCredential is what makes one code path work in both places: a deployed host authenticates with its managed identity, a developer machine falls back to the Azure CLI or Visual Studio sign-in, and the identity needs the Key Vault Secrets User role on the vault (doc comment :49-55).
    • When the read happens is the detail that dictates where the call goes (doc comment :63-69): the source is added to builder.Configuration, whose ConfigurationManager builds and loads each source as it is added, so the vault is read synchronously at this point in startup. That is what makes the secrets visible to everything registered afterwards, and it is why the call belongs early in the host builder, ahead of the settings binding and the service registrations that read them.
  • Why it's built this way: Deliberately not called from AddServiceDefaults(), and the doc comment says so in as many words (:56-62): service defaults run in every host, developer machine and test host and Helpdesk seed included, so an unconditional Azure dependency at startup would be a liability there rather than a feature. A host that wants vault-backed configuration opts in with this one call, exactly like DataProtectionExtensions. The loud failure on a bad reload interval is the same fail-fast contract as ADR-070: a misconfiguration whose only symptom would appear later, during an incident, is worth a startup crash. ADR-061 records the platform half of the same posture (production secrets as keyVaultUrl references resolved by a user-assigned managed identity); this extension is the in-process complement, adding the whole vault as a configuration source rather than binding secrets one env var at a time.
  • Where it's used: Five ADC hosts (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:41, MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:105, MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:110, MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:92, MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:95) and five Store deployables (MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:63, MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:41, MMCA.Store/Source/Services/MMCA.Store.Identity.Service/Program.cs:47, MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:41, MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:59). Each call sits immediately after AddServiceDefaults() in the ADC hosts and immediately before it in the Store ones, and always ahead of any settings binding. Covered by KeyVaultConfigurationExtensionsTests, which asserts the gate, the appended source with and without a reload interval, and both throwing paths.
  • Caveats / not-in-source: A malformed KeyVault:Uri throws UriFormatException from new Uri(...) (documented at :72-74); source contains no explicit URI validation of its own. Nothing here verifies the identity actually holds the Secrets User role: because the source loads synchronously as it is added, an authentication failure is a startup crash rather than a degraded feature.

RedisCachingExtensions

MMCA.Common.Aspire · MMCA.Common.Aspire.Caching · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Caching/RedisCachingExtensions.cs:29 · Level 0 · class (static)

  • What it is: The framework-owned way to wire a host to Redis. AddRedisCaching() registers the distributed cache plus the IConnectionMultiplexer client, and AddRedisOutputCaching() backs the ASP.NET Core output cache with the same instance. Both are no-ops when the connection string is absent, and both exist specifically so a host never calls Aspire's Redis integrations directly.
  • Depends on: The Aspire Redis client integrations (AddRedisDistributedCache, AddRedisClient) and Microsoft.Extensions.Caching.StackExchangeRedis (AddStackExchangeRedisOutputCache), over IHostApplicationBuilder. It pairs with RedisPingHealthCheck and HealthCheckTags (the replacement check registered from Extensions), and downstream with DistributedCacheService, which needs the multiplexer.
  • Concept introduced, wrapping a third-party integration to own its health-check contract. [Rubric §29, Resilience & Business Continuity], [Rubric §13, Observability & Operability], [Rubric §11, Security], [Rubric §15, Best Practices & Code Quality]. The class doc comment (RedisCachingExtensions.cs:11-27) is a production incident write-up, dated 2026-09-02, and it is the clearest worked example in the codebase of why the tag vocabulary above is load-bearing. Aspire's Redis integrations register the AspNetCore.HealthChecks.Redis check under the name StackExchange.Redis with no tags (:12-14). The readiness endpoint from MapDefaultEndpoints() includes every check that is not tagged live or optional, so an untagged check silently gates readiness (:14-15). That check issues CLUSTER INFO whenever the client detects a clustered server, and Azure Managed Redis (Enterprise tier, port 10000) is detected as a cluster by StackExchange.Redis 3.x, which refuses the command without admin mode (:16-18). Every probe therefore threw against a healthy cache, every replica reported not ready, and the platform stopped routing traffic the applications were perfectly able to serve (:18-19). The teaching point is the shape of the fix (:21-27): rather than patching a tag onto someone else's registration, the Aspire checks are switched off at the source with DisableHealthChecks and Common contributes its own PING-only check, named redis and tagged Optional, from AddInfrastructureHealthChecks().
  • Walkthrough:
    • DefaultConnectionName = "redis" (:34) is the connection-string name every MMCA host uses for its Redis resource, so the two methods default to it and a host normally calls them with no arguments.
    • Both methods live in a generic extension<TBuilder>(TBuilder builder) where TBuilder : IHostApplicationBuilder block (:36-37), the same shape as the other opt-in host extensions in this group.
    • AddRedisCaching(connectionName = DefaultConnectionName) (:57) returns immediately when GetConnectionString(connectionName) is null or blank (:59-62). Past that gate it calls builder.AddRedisDistributedCache(connectionName, settings => settings.DisableHealthChecks = true) (:64) and builder.AddRedisClient(connectionName, settings => settings.DisableHealthChecks = true) (:65). Registering the client alongside the cache is deliberate (doc comment :43-50): DistributedCacheService needs an IConnectionMultiplexer for SCAN-based prefix eviction, and without it every ICacheInvalidating command's prefix invalidation degrades to a silent no-op bounded only by TTL. That is a failure mode with no error and no log line, which is why the two registrations are welded together in one call.
    • AddRedisOutputCaching(connectionName = DefaultConnectionName) (:91) reads the same connection string (:93), returns untouched when it is missing (:94-97), and otherwise registers AddStackExchangeRedisOutputCache(options => options.Configuration = connectionString) (:99). The doc comment (:70-84) covers three things: cross-replica tag eviction is the reason it exists (the built-in store is per-replica memory, so an EvictByTagAsync reaches only the replica that served the mutation); it should be called before AddOutputCache(...), which registers its store with TryAdd so the explicit Redis store wins either way, but the order documents the intent; and this integration registers no health check of its own, so there is nothing to disable in this method.
  • Why it's built this way: The wrapper is a structural fix, not a patch. A tag added to another package's registration would have to be re-applied every time that package changed its registration shape, and the incident showed that an untagged check is invisible until a specific server tier trips it. Disabling the vendor checks at the source and contributing one PING-only check the framework owns makes the readiness contract explicit and testable. ADR-025 is the record consumers cite at the call site for the "readiness checks are PING-class" rule (for example MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:116).
  • Where it's used: All four ADC services call AddRedisCaching() unconditionally, since the wrapper self-gates: Identity (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:123), Conference (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:131), Engagement (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:103), and Notification (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:106); ADC Conference also calls AddRedisOutputCaching() (:140). The three Store services call it inside an explicit if on the connection string (MMCA.Store/Source/Services/MMCA.Store.Identity.Service/Program.cs:92, MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:86, MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:103), because that guard also scopes the hybrid-cache registration beside it, which has no self-gate of its own (MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:84-85); Store Catalog adds AddRedisOutputCaching() in the same branch (:104). Covered by RedisReadinessSafetyTests, which asserts that the wrapper registers no untagged Aspire check, that every Redis check is tagged Optional, that the readiness predicate admits none of them, and that both methods register nothing without a connection string.
  • Caveats / not-in-source: The methods gate on the connection string only; nothing here verifies the endpoint is reachable, which is what the separate PING check is for. AddRedisOutputCaching reads the connection string directly rather than going through Aspire's client integration, so it does not participate in that integration's options binding.

Extensions

MMCA.Common.Aspire · MMCA.Common.Aspire · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:29 · Level 11 · class (static)

Disambiguation: this is the framework (Common.Aspire) Extensions, the canonical service-defaults bootstrap consumed by every running service, including all four ADC services and all three Store services (neither app ships a local ServiceDefaults project). The other class named Extensions in this chapter is the Common.Aspire.Hosting Extensions, the AppHost-side broker, JWKS, and data-source wiring.

  • What it is: The shared Aspire service-defaults bootstrap: AddServiceDefaults(), AddWarmupReadiness(), ConfigureOpenTelemetry(), AddDefaultHealthChecks(), AddInfrastructureHealthChecks(), AddWarmupTask<T>(), and MapDefaultEndpoints(). It configures OpenTelemetry (logs, metrics, tracing), health checks (/health, /alive, /health/ready), service discovery, the warm-up readiness pipeline, and a Polly resilience pipeline plus a FinOps-tuned SocketsHttpHandler for every outbound HttpClient.
  • Depends on: IWarmupTask, OpenIdConnectMetadataWarmupTask, WarmupHostedService, WarmupReadinessGate, WarmupReadinessHealthCheck, HealthCheckTags, HealthEndpointPaths, RedisPingHealthCheck, OutboxPollFilterProcessor, ProbeTelemetryFilter, ProbeTelemetryFilterProcessor, and HttpResilienceDefaults; plus Azure Monitor, OpenTelemetry, Polly / Microsoft.Extensions.Http.Resilience, the AspNetCore health-check packages, and the RabbitMQ client (NuGet).
  • Concept introduced, Aspire service defaults as a shared cross-cutting bootstrap. [Rubric §13, Observability & Operability] (centralised OpenTelemetry plus health endpoints; it subscribes seven MMCA meters and the MMCA.Common.Outbox trace source by literal name, and installs OutboxPollFilterProcessor to drop noisy idle-poll spans before export, plus, by default, the two probe-telemetry filters). [Rubric §29, Resilience & Business Continuity] (Polly on every outbound client, ADR-009). [Rubric §31, Cost / FinOps] (three trace-side cost knobs, Telemetry:FilterProbeTelemetry on by default plus Telemetry:TracesSampleRatio and the two metric-family switches opt-in, and the SocketsHttpHandler tuning at Extensions.cs:85-93, whose rationale comment at :74-84 is the codebase's clearest FinOps decision: PooledConnectionLifetime picks up ACA replica DNS rollover, PooledConnectionIdleTimeout avoids repeated TLS handshakes, and socket keep-alive pings keep TCP alive without counting as ACA user traffic, so the replica stays on idle-vCPU billing, roughly 8x cheaper than active). [Rubric §13, Observability & Operability] (one call, one baseline, every host). The telemetry half of this is ADR-041; the warm-up half is ADR-025.
  • Walkthrough:
    • AddServiceDefaults<TBuilder>() (Extensions.cs:46, in a generic extension<TBuilder>(TBuilder builder) where TBuilder : IHostApplicationBuilder block at :37-38, so it works for a web host or a worker): calls ConfigureOpenTelemetry, AddDefaultHealthChecks, AddWarmupReadiness, then AddServiceDiscovery (:48-51), then ConfigureHttpClientDefaults (:55), which affects every HttpClient registered downstream (typed clients, named clients, the YARP forwarder, and the gateway's own downstream probe clients). Inside it, AddStandardResilienceHandler pulls all four values from HttpResilienceDefaults (:65-71): a 30-second per-attempt timeout, a 60-second circuit-breaker sampling window, a 90-second total request timeout, and MaxRetryAttempts = 1. http.AddServiceDiscovery() (:72) lets clients resolve logical service names, and ConfigurePrimaryHttpMessageHandler installs the tuned SocketsHttpHandler (:85-93) including KeepAlivePingPolicy.WithActiveRequests and EnableMultipleHttp2Connections = true (:91-92, so one multiplexed HTTP/2 connection cannot become a bottleneck). The comment at :57-64 is worth reading for the retry-budget reasoning: exactly one retry per hop, because the UI service base classes own user-facing retries, and stacking full budgets at every hop previously multiplied a backend brownout into up to sixteen gateway hits per user action.
    • AddWarmupReadiness() (Extensions.cs:108): registers the singleton WarmupReadinessGate (:110), the WarmupHostedService runner (:111), the built-in OpenIdConnectMetadataWarmupTask as an IWarmupTask (:113), and the WarmupReadinessHealthCheck as the "warmup" check tagged HealthCheckTags.Ready (:115-116). AddWarmupTask<TTask>() (:390, in an extension(IServiceCollection services) block at :381) lets a consumer add service-specific warm-ups by registering AddSingleton<IWarmupTask, TTask>() (:392).
    • ConfigureOpenTelemetry() (Extensions.cs:128): logging with IncludeFormattedMessage and IncludeScopes (:132-133); metrics always add ASP.NET Core instrumentation (:139), then handle the two metrics cost knobs. Each knob is a two-part switch, and the second part is the non-obvious one. For Telemetry:DisableHttpClientMetrics (:148) the disabled branch does not merely skip AddHttpClientInstrumentation: it installs a metrics.AddView(...) that returns MetricStreamConfiguration.Drop for the System.Net.Http and System.Net.NameResolution meters (:160-164). The comment above it (:150-159) records why: a deployed host also calls UseAzureMonitor(), and the Azure Monitor distro adds the System.Net.Http meter itself, so http.client.open_connections kept flowing and stayed the single largest AppMetrics stream in both production workspaces despite the toggle being on. A View applies to the whole MeterProvider regardless of which component added the meter, which is what makes the toggle authoritative rather than advisory. The Telemetry:DisableRuntimeMetrics branch (:175) applies the identical shape to the System.Runtime meter (:180-183). When either knob is off, the corresponding AddHttpClientInstrumentation() (:168) or AddRuntimeInstrumentation() (:187) is added normally. It then subscribes seven MMCA meters by literal name (:199-205): MMCA.Common.Outbox, .Cqrs, .Idempotency, .Scheduler, .Broker, .OutputCache, and .BestEffort. The comment above them (:190-198) names what each carries and which are inert until a host opts in (the scheduler meter in a host that never enables Scheduler:Enabled, the broker meter in a host that stays on the in-process bus). Tracing adds the application's own source plus MMCA.Common.Outbox (:209-210), then branches on the third cost knob: IsProbeTelemetryFilterEnabled (:224). When filtering is on (the default), ASP.NET Core and HttpClient instrumentation are added with filters, options.Filter = Telemetry.ProbeTelemetryFilter.ShouldCollectRequest and options.FilterHttpRequestMessage = Telemetry.ProbeTelemetryFilter.ShouldCollectOutgoing (:230-233); when it is off they are added plain (:235-239). The comment above the branch (:212-223) carries the measurement behind it: Container Apps liveness and readiness probes, the gateway's downstream aggregate probes, YARP active health checks and the availability web test made up 100% of the AppRequests rows in both production workspaces, and their children (the health check's SELECT 1, the Redis PING, the gateway's calls to each backend's /alive) most of the AppDependencies rows; sampling cannot help there, because probe spans are exactly what the sampler keeps proportionally. It also records what is deliberately left alone: metrics, so probe traffic still shows on dashboards. Because both filters configure the default-named instrumentation options, they also apply to the instrumentation the Azure Monitor distro adds, which is why this knob needs no View (:227-229), unlike the metrics knobs above. Two processors follow: .AddProcessor(new Telemetry.OutboxPollFilterProcessor()) (:246) always, and new Telemetry.ProbeTelemetryFilterProcessor() (:253) only under the same knob, because the inbound filter refuses only the probe request span itself while its dependency children are sampled independently (:249-252). Both must be registered here, before the exporters, so their OnEnd clears the Recorded flag first (:241-245). When TryGetTraceSampleRatio succeeds it installs new ParentBasedSampler(new TraceIdRatioBasedSampler(ratio)) (:261-262), parent-based so a sampled-in request keeps its whole trace intact across service boundaries. Finally it calls the private AddOpenTelemetryExporters (:265).
    • TryGetTraceSampleRatio(configuration, out ratio) (Extensions.cs:448, internal static): reads the optional Telemetry:TracesSampleRatio knob and returns true only for a value that parses invariantly and falls in the open interval (0,1); absent, unparseable, or out-of-range input returns false and leaves ratio at 1.0 (:450-460). Sampling therefore fails toward keeping everything, so a typo can never silently drop all telemetry.
    • IsInstrumentationDisabled(configuration, configKey) (Extensions.cs:471, internal static): the same fail-safe shape for the two metric-family knobs, expressed as one line, bool.TryParse(configuration[configKey], out var disabled) && disabled (:472). It returns true (drop that family) only when the value parses as boolean true; absent, blank, or unparseable keeps the instrumentation.
    • IsProbeTelemetryFilterEnabled(configuration) (Extensions.cs:483, internal static) reads the key held as FilterProbeTelemetryConfigKey = "Telemetry:FilterProbeTelemetry" (:35) and inverts the default: !bool.TryParse(configuration[FilterProbeTelemetryConfigKey], out var enabled) || enabled (:484), so absent, blank, or unparseable all mean "filter" and only an explicit false turns filtering off. That is the opposite bias from the two metrics knobs, and the doc comment says why (:475-482): probe chatter is ingestion no host wants billed, while a metrics family dropped by accident is a blind spot.
    • AddDefaultHealthChecks() (Extensions.cs:276) registers a "self" check tagged HealthCheckTags.Live (:278-279), and nothing else. That single check is what /alive answers with.
    • AddInfrastructureHealthChecks(bool requireDatabase = false) (Extensions.cs:308) is the conditional part. It resolves the health-checks builder (:310), delegates the relational engines to the private AddDatabaseHealthChecks (:312), then adds Redis and RabbitMQ only when their connection strings are present. The Redis branch (:314-331) is the incident-hardened one: it TryAddSingletons a RedisPingHealthCheck built from the connection string (:323-324) and registers it as a HealthCheckRegistration named "redis" tagged [HealthCheckTags.Optional] (:326-330). The comment above it (:317-322) explains both halves: PING only, never an administrative command, because the AspNetCore.HealthChecks.Redis check this replaced issued CLUSTER INFO against any server StackExchange.Redis 3.x detects as clustered (which is how it sees Azure Managed Redis Enterprise tier) and the server refuses that command outside admin mode, so every probe threw against a healthy cache; and singleton registration so the fallback multiplexer is built once, not per probe, and disposed with the container. The RabbitMQ branch (:333-343) reads the rabbitmq connection string falling back to messaging (:333-334), requires it to parse as an absolute URI (:335-336), and registers an AddRabbitMQ check named "rabbitmq" tagged [HealthCheckTags.Optional] (:338-342) whose factory builds a connection per probe.
    • AddDatabaseHealthChecks(healthChecks, configuration, requireDatabase) (Extensions.cs:498, private static) holds the asymmetry that matters. It collects the SQL Server and SQLite sources (:503-504), and when requireDatabase is true and both are empty it throws InvalidOperationException with a message naming both accepted shapes (:506-511). Otherwise it adds one AddSqlServer check per SQL source (:513-516) and one AddSqlite check per SQLite source (:518-521), all untagged, so every one of them gates readiness. The doc comment on the parameter (:290-301) states the reasoning: Redis and RabbitMQ are optional per host, so their absence is a valid configuration, but a host that cannot resolve its own database connection string is misconfigured, and silently registering no check would let it report healthy and take traffic it cannot serve. The requirement is engine-agnostic on purpose, because an application picks its engine from configuration.
    • RelationalSources(configuration, key) (Extensions.cs:541, private static) is the small piece of logic behind "one check per database". It derives the engine name from the key (:543), reads the top-level ConnectionStrings entry (:547-551) and then every named DataSources child carrying the same key (:553-560), and finally deduplicates by connection string with an ordinal HashSet (:562-570). Two details are deliberate and documented in the remarks (:534-540): the first surviving database keeps the historical check name for its engine (sqlserver / sqlite) and only a genuinely second, different database gets the {engine}:{source} form; and because deduplication is by connection string, the logical names that collapse onto one physical database (the resolver's single-database collapse, which is exactly what the AppHost's WithSQLServerDataSource produces) contribute one check rather than one per logical name.
    • MapDefaultEndpoints() (Extensions.cs:411, in an extension(WebApplication app) block at :398): /health maps every check (:413); /alive filters to HealthCheckTags.Live (:417-420) so a downstream SQL outage cannot get the container restarted; /health/ready maps everything tagged neither Live nor Optional (:433-436). All three routes come from the HealthEndpointPaths constants rather than literals, which is what keeps the probe-telemetry filters matching the routes actually mapped. The comment above the readiness map (:422-432) is the canonical explanation of why optional is excluded.
    • AddOpenTelemetryExporters() (Extensions.cs:361, private): enables OTLP when OTEL_EXPORTER_OTLP_ENDPOINT is set (:363-369, the local Aspire dashboard sets it) and Azure Monitor when APPLICATIONINSIGHTS_CONNECTION_STRING is set (:371-377, set by the Container Apps Bicep). Both can be active simultaneously, and each exporter ships only its own copy.
  • Why it's built this way: One bootstrap means a baseline change (a new meter, a tighter timeout, the poll filter, the Redis check swap) propagates to every consumer in lockstep. There is a single framework copy and no per-app variants: neither ADC nor Store ships an app-local ServiceDefaults project, so the warm-up gate, the MMCA meters, the poll filter, and the Azure Monitor exporter branch are uniform across every service. Every knob that could hide end-user signal defaults to the expensive-but-safe setting, which is the deliberate bias recorded in ADR-041: a cost surprise is recoverable, a silent telemetry blackout during an incident is not. The one knob that defaults the other way, Telemetry:FilterProbeTelemetry (:483-484), is the exception that proves the rule: what it drops is the platform probing itself, carries no user signal, and stays visible in metrics. The class also carries a scoped CA1708 suppression (:25-28) documenting a known analyzer false positive: with two or more extension(T) blocks in one static class, the compiler-generated grouping members trip the "identifiers should differ by more than case" rule.
  • Where it's used: builder.AddServiceDefaults() early in each framework-consuming host's Program.cs: ADC Gateway (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:57), ADC UI (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:33), the four ADC services (Identity :97, Conference :100, Engagement :83, Notification :86), the Store gateway, UI and three services (MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:65, MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:76, MMCA.Store/Source/Services/MMCA.Store.Identity.Service/Program.cs:74, MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:68, MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:85), and the Helpdesk seed (MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:14). app.MapDefaultEndpoints() follows Build() in each (ADC Gateway :136, ADC UI :119, ADC Conference :375, Helpdesk :129). AddInfrastructureHealthChecks(requireDatabase: true) is the standard service-host call: ADC Identity :146, Conference :163, Engagement :143, Notification :128, Store Identity :129, Catalog :133, Sales :142, and Helpdesk :31. The two internal cost-knob helpers are covered by TracesSampleRatioTests and MetricsInstrumentationToggleTests; the conditional infrastructure checks by InfrastructureHealthChecksTests and RedisReadinessSafetyTests.
  • Caveats / not-in-source: requireDatabase defaults to false, so a service host that forgets to pass true registers no database check and can report ready without one; every current service host passes it. The RabbitMQ branch silently registers nothing when the connection string is present but not a valid absolute URI (:335-336), so a malformed broker URI produces no check and no error at startup. Probe-trace filtering is on by default, so a host debugging its own probes sees no request traces for them until it sets Telemetry:FilterProbeTelemetry=false; nothing logs that the filter is active.

DownstreamProbeVersion

MMCA.Common.Aspire · MMCA.Common.Aspire.Gateway · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Gateway/GatewayHealthCheckExtensions.cs:10 · Level 0 · enum

  • What it is: The three-value vocabulary for "which HTTP version does the gateway's downstream /alive probe ask for": negotiate it, or pin one of the two answers.
  • Depends on: Nothing first-party. Its doc comments reference HttpVersionPolicy (BCL). It is carried on GatewayDownstreamHealthCheckOptions and consumed by DownstreamServiceHealthCheck.
  • Concept introduced, protocol negotiation as a health-check concern. [Rubric §13, Observability & Operability], [Rubric §7, Microservices Readiness], [Rubric §29, Resilience & Business Continuity]. §13 assesses whether an operational signal tells the truth, and this enum exists because a probe that asks the wrong protocol lies in the most expensive direction: it reports a downstream outage that does not exist. The two facts that force the choice are in the options doc comment (GatewayHealthCheckExtensions.cs:45-53). A service serving cross-service gRPC on cleartext runs Kestrel Http2-only, so h2c prior knowledge is the only thing it answers, and a stock HttpClient sends HTTP/1.1, which it refuses. Sending HTTP/2 unconditionally has the mirror-image failure: an HTTP/1.1-only endpoint, or a mixed Http1AndHttp2 cleartext endpoint where Kestrel disables h2 because there is no ALPN to disambiguate, answers HTTP_1_1_REQUIRED forever. Neither fixed answer is right for every head, so the framework makes discovery the default and pinning the exception. ADR-012, in its 2026-08-29 update, is the governing record.
  • Walkthrough:
    • Auto (:18) is the zero value and the default. It tries HTTP/2 first and, when the downstream refuses the protocol rather than the connection, falls back to HTTP/1.1 inside the same check, so a single readiness poll still produces one verdict (doc comment :12-17). The version that answered is then latched for the life of the process, making the fallback a one-time cost per downstream rather than a per-poll one.
    • Http2 (:25) always sends HTTP/2 over cleartext with HttpVersionPolicy.RequestVersionExact, so the request never negotiates down (:20-24).
    • Http11 (:32) always sends HTTP/1.1 with RequestVersionOrLower, which is exactly stock HttpClient behavior (:27-31).
    • That Auto is the zero value is load-bearing beyond defaulting: the latch field in DownstreamServiceHealthCheck stores the underlying int and reads zero as "not settled yet" (DownstreamServiceHealthCheck.cs:48-55).
  • Why it's built this way: An enum rather than a bool because there are genuinely three states (discover, pin high, pin low), and the previous shape (a per-downstream ProbeOverHttp2 boolean opt-out) could express only two. ADR-012 records that the boolean was removed outright in v1.168.0 with no compatibility facade left behind. Both pinned values are documented as optimisations ("to skip the one-time negotiation"), never as correctness requirements, which is what keeps a single AddGatewayDownstreamHealthChecks call correct for a mixed fleet.
  • Where it's used: Set on GatewayDownstreamHealthCheckOptions (GatewayHealthCheckExtensions.cs:59), captured at registration (:174), passed into each check (:209), and branched on at probe time (DownstreamServiceHealthCheck.cs:68, :149). Neither gateway pins a value today: ADC registers all four downstreams in one call (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:78) and Store all three (MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:112), both relying on Auto. Covered by GatewayDownstreamHealthChecksTests, whose Options_DefaultToAutoNegotiation and the pinned-mode cases (Probe_WhenPinnedToHttp2_NeverFallsBack, Probe_WhenPinnedToHttp11_SendsHttp11) pin all three values.

GatewayDownstreamRegistry

MMCA.Common.Aspire · MMCA.Common.Aspire.Gateway · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Gateway/GatewayHealthCheckExtensions.cs:225 · Level 0 · class (sealed, internal)

  • What it is: A registration-time ledger of the downstream service names already wired by AddGatewayDownstreamHealthChecks, so a repeated name cannot register a second health check under the same name.
  • Depends on: IServiceCollection (it stores itself in the collection as a singleton instance) and StringComparer (BCL). Used only by GatewayHealthCheckExtensions; it hands out no runtime behaviour. Its AppHost-tier twin is H2cHealthCheckRegistry.
  • Concept introduced, registration-time state parked in the service collection. [Rubric §15, Best Practices & Code Quality], [Rubric §15, Best Practices & Code Quality]. IServiceCollection is a list of descriptors, not a queryable model: there is no supported way to ask "is a health check named X already registered", and a duplicate health-check name is a startup exception rather than a harmless second registration (class doc comment GatewayHealthCheckExtensions.cs:219-224). The idiom the framework reaches for is to park a small mutable object in the collection itself as an ImplementationInstance and look it up on the next call, which makes idempotence a property of the registration API rather than a rule the caller has to remember.
  • Walkthrough:
    • private readonly List<string> _names = [] (:232). The doc comment above it (:227-231) explains the choice of a list over a case-insensitive HashSet<string>: a gateway fronts a handful of services, and the comparer-carrying HashSet constructor is one of the initializer shapes the IDE0028 analyzer misreports here.
    • TryClaim(serviceName) (:240) returns false when the name is already present, compared with StringComparer.OrdinalIgnoreCase (:242-245), otherwise appends it and returns true (:247-248). Case-insensitivity is what makes a second call passing "CATALOG" a no-op rather than a duplicate.
    • GetOrAdd(services) (:257) walks the descriptors looking for one whose ServiceType is this type and whose ImplementationInstance is a registry (:259-266), returning it when found; otherwise it constructs one, registers it with AddSingleton(registry) and returns it (:268-270).
  • Why it's built this way: internal sealed, because it is an implementation detail of one extension method and part of no public contract; the assembly's <InternalsVisibleTo Include="MMCA.Common.Aspire.Tests" /> (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/MMCA.Common.Aspire.csproj:13) is what keeps it reachable from its tests. Scanning for an ImplementationInstance rather than building a provider is deliberate: this runs during registration, when no provider exists yet.
  • Where it's used: The shared registration body fetches it once per call (GatewayHealthCheckExtensions.cs:176) and claims each name in the loop (:181). The idempotence it buys is asserted by GatewayDownstreamHealthChecksTests (AddGatewayDownstreamHealthChecks_IsIdempotent, which calls the method twice with overlapping and differently-cased names).
  • Caveats / not-in-source: The registry is a registration-time artifact that stays registered as a singleton in the built provider; nothing consumes it at runtime. It also lives per IServiceCollection, so two independently-built collections do not share claims, which is what makes the tests independent.

GatewayRateLimitingSettings

MMCA.Common.Aspire · MMCA.Common.Aspire.Gateway · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Gateway/GatewayRateLimitingSettings.cs:47 · Level 0 · class (sealed)

  • What it is: The bound configuration for the edge limiter: how many requests one client IP gets per window, how much work the replica will hold at once, which path prefixes are exempt, and the header-plus-secret pair a synthetic-traffic run presents to skip both limiters. Every property has a working default, so the section is optional.
  • Depends on: System.ComponentModel.DataAnnotations ([Range], [Required], [StringLength]) only. Consumed by GatewayRateLimitingExtensions. Its service-tier counterpart is RateLimitingSettings in MMCA.Common.API.
  • Concept introduced, the per-replica limiter and what it can and cannot promise. [Rubric §11, Security], [Rubric §12, Performance & Scalability], [Rubric §29, Resilience]. The remarks on this type (GatewayRateLimitingSettings.cs:10-35) are the teaching material and are worth reading whole. Both limiters count in this process's memory, so the effective allowance is the configured number multiplied by the replica count: three replicas admit roughly 3 x PermitLimit per window from one client IP. That is the accepted trade, not an oversight. An edge limiter exists to stop one misbehaving caller from exhausting a replica's sockets and threads, and it must answer in microseconds on every request, whereas a shared counter puts a network round trip in front of the whole edge and fails open (or fails the edge) whenever the counter store blips. When a limit has to mean the same thing fleet-wide, the distributed counter in MMCA.Common.API's RateLimitingSettings.Distributed is the tool. The second remark (:22-27) is the operational trap: partitioning is by Connection.RemoteIpAddress, so UseForwardedHeaders must run before UseGatewayRateLimiting or every request behind an ingress is attributed to the ingress and collapses into one window.
  • Concept introduced, the synthetic-traffic bypass. [Rubric §11, Security], [Rubric §12, Performance & Scalability]. The third remark (:28-34) names a failure mode that only appears once a system has a scheduled capacity proof: a load run drives its whole traffic from one runner IP, so the per-IP window cannot tell it from an unauthenticated flood and the run ends up measuring the limiter instead of the system. The answer here is a proof-of-secret, not a configuration switch: a run that presents the configured header carrying the configured secret takes the same no-limiter partition a bypassed path takes, and the whole mechanism is off unless the secret is set.
  • Walkthrough:
    • SectionName = "GatewayRateLimiting" (:50), a static readonly string used for options binding.
    • PermitLimit (:59, [Range(1, 1_000_000)] at :58) defaults to 120, requests per window per client IP per replica. Its doc comment (:52-57) states the deliberate divergence from the service tier: this applies to anonymous traffic too, because the service-side global limiter exempts anonymous callers (public reads are output-cached and Blazor Server traffic shares one host IP) and the edge is exactly where an unauthenticated flood has to be stopped.
    • WindowSeconds (:63, [Range(1, 3600)] at :62) defaults to 60.
    • GlobalConcurrencyLimit (:73, [Range(1, 1_000_000)] at :72) defaults to 200. Its doc comment (:65-71) explains why a concurrency cap sits beside a rate limit: the failure it guards against is a slow downstream backing requests up until the edge runs out of threads and sockets, which no rate limit prevents and only a ceiling on simultaneous work does. Excess is rejected immediately with 429 rather than queued, so a saturated edge sheds load instead of growing latency.
    • BypassPathPrefixes (:82) is an IReadOnlyList<string>, empty by default, matched case-insensitively on whole path segments. The doc comment (:75-81) records that /health, /alive and /.well-known are always exempt regardless of this list, because probes and JWKS discovery run at high frequency by design and throttling them turns a traffic spike into a failed liveness probe and a container restart.
    • SyntheticTrafficHeaderName (:91, [Required] at :90) defaults to "X-Synthetic-Traffic-Key". Only the name lives here, and the doc comment (:84-89) says why that is safe in appsettings.json: the header is worthless without the secret, so renaming it is a coordination change rather than a security control.
    • SyntheticTrafficSecret (:115, [StringLength(int.MaxValue, MinimumLength = 32)] at :114) is nullable and null by default, which disables the bypass entirely so no header value can claim it. The remarks (:98-113) carry two rules. It must come from a secret store or the environment (GatewayRateLimiting__SyntheticTrafficSecret) and never from a checked-in appsettings file, because a value in source control is a published key to the edge limiter. And the 32-character floor is validated at registration, so a short value fails startup rather than shipping a guessable bypass; StringLength treats null as valid, which is exactly the intended "off" state.
  • Why it's built this way: sealed with init-only properties, so the settings object the limiter closes over cannot be mutated after validation. Data-annotation attributes rather than hand-written guards, because both entry points into the limiter validate them: the configuration overload through ValidateDataAnnotations().ValidateOnStart() and the object overload through an explicit Validator.ValidateObject call, which is ADR-070's fail-fast contract honoured on both paths. The per-replica choice, the anonymous inclusion, the two-tier bypass and the synthetic-traffic amendment are all recorded in ADR-088, which extends ADR-019 with this fourth, edge tier.
  • Where it's used: Bound from the "GatewayRateLimiting" section by AddGatewayRateLimiting(IConfiguration) (GatewayRateLimitingExtensions.cs:193-200) and read by the bypass test and the two partition functions (:99-113, :131, :144-151, :169-176). Both consumers configure it in the gateway appsettings.json beside the route table: ADC keeps the defaults and bypasses /hubs (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:21-26), because a SignalR negotiate-plus-reconnect storm from one shared office address is the pattern a per-IP window misreads as abuse; Store keeps the defaults and bypasses /Payments (MMCA.Store/Source/Hosts/MMCA.Store.Gateway/appsettings.json:16-21), because a 429 to a Stripe webhook is a retry and eventually a disabled endpoint. The secret is deployment-only in both apps: a Key Vault secret named synthetic-traffic-secret mapped onto GatewayRateLimiting__SyntheticTrafficSecret and present only when the parameter is supplied (MMCA.ADC/infra/main.bicep:979-982, :1690; MMCA.Store/infra/main.bicep:924-927), and ADC's scheduled load test presents it as X-Synthetic-Traffic-Key (MMCA.ADC/.github/workflows/load-test.yml:8-11).
  • Caveats / not-in-source: The range bounds constrain each value independently; nothing checks that GlobalConcurrencyLimit is sensible relative to PermitLimit, or that a bypass prefix matches a real route. The multiplication by replica count is documented on the type but not enforced or measured anywhere in source.

H2cHealthCheckRegistry

MMCA.Common.Aspire.Hosting · MMCA.Common.Aspire.Hosting · MMCA.Common/Source/Hosting/MMCA.Common.Aspire.Hosting/H2cHealthCheckExtensions.cs:153 · Level 0 · class (sealed, internal)

  • What it is: The AppHost-tier twin of GatewayDownstreamRegistry: a registration-time ledger of the resource-and-endpoint pairs already wired by WithH2cHealthCheck, so a repeated call cannot register a duplicate health-check key.
  • Depends on: IServiceCollection and StringComparer (BCL). Used only by H2cHealthCheckExtensions.
  • Concept reinforced: the registration-time ledger, taught at GatewayDownstreamRegistry. [Rubric §15, Best Practices & Code Quality], [Rubric §14, Testability]. The one idea this copy adds is in its own class doc (H2cHealthCheckExtensions.cs:147-151): the ledger is attached to the AppHost's own service collection rather than kept in a static field, so two DistributedApplication.CreateBuilder instances in one process, which is exactly what a test run is, never see each other's claims. A static ledger would make the second test in a file silently skip its registration.
  • Walkthrough: private readonly List<string> _keys = [] (:160), with the same list-over-HashSet note the gateway registry carries (:155-159). TryClaim(key) (:168) compares case-insensitively and returns false on a repeat (:170-173), otherwise appends and returns true (:175-176). GetOrAdd(services) (:185) scans the descriptors for a matching ImplementationInstance (:187-194) and otherwise registers a fresh one as a singleton (:196-198). The keys it holds are built by CheckKey(resourceName, endpointName) (:75-76), so the endpoint name is part of the identity and one resource can gate two endpoints.
  • Why it's built this way: internal sealed, reachable from its tests through <InternalsVisibleTo Include="MMCA.Common.Aspire.Hosting.Tests" /> (MMCA.Common/Source/Hosting/MMCA.Common.Aspire.Hosting/MMCA.Common.Aspire.Hosting.csproj:7). It is a deliberate duplicate rather than a shared type: this assembly is the AppHost tier and must not reference the service-defaults assembly (see Extensions for the same packaging rule applied to configuration section names).
  • Where it's used: WithH2cHealthCheck claims its key before registering anything (H2cHealthCheckExtensions.cs:121-124). Both behaviours it buys are pinned by H2cHealthCheckExtensionsTests: WithH2cHealthCheck_IsIdempotent, WithH2cHealthCheck_KeysTheCheckByEndpointSoOneResourceCanGateTwo, and WithH2cHealthCheck_DoesNotShareItsDuplicateGuardAcrossBuilders.

RedisPingHealthCheck

MMCA.Common.Aspire · MMCA.Common.Aspire.Health · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Health/RedisPingHealthCheck.cs:26 · Level 0 · class (sealed, internal)

  • What it is: The framework's own Redis reachability check. It issues PING and nothing else, and reports the round-trip latency as its healthy description.
  • Depends on: StackExchange.Redis (IConnectionMultiplexer, ConnectionMultiplexer), IHealthCheck / HealthCheckResult (ASP.NET Core diagnostics), and IServiceProvider. It pairs with RedisCachingExtensions' AddRedisCaching() and is registered by the Common.Aspire Extensions under the HealthCheckTags Optional tag.
  • Concept introduced, a health check must not ask a question only an administrator can answer. [Rubric §13, Observability & Operability], [Rubric §29, Resilience & Business Continuity], [Rubric §11, Security]. The class doc (RedisPingHealthCheck.cs:8-24) records a real incident shape. The AspNetCore.HealthChecks.Redis check this replaced branches on the detected server type and issues CLUSTER INFO against anything it considers clustered. Azure Managed Redis (Enterprise tier, port 10000) is detected as a cluster by StackExchange.Redis 3.x and refuses administrative commands unless the client opted into admin mode, so that check threw RedisCommandException on every probe against a perfectly healthy cache. The lesson generalises: a command whose success depends on the caller's privileges reports the caller as much as the dependency, and a probe cannot tell those two answers apart. PING answers the only question a health check has, "can this process talk to Redis right now", and is never gated on admin mode, on server topology, or on deployment tier. ADR-025 states the rule and names this file as its implementation.
  • Walkthrough:
    • Four fields (:28-31): the connection string, the provider, a SemaphoreSlim(1, 1) connect gate, and a nullable _ownedMultiplexer. The constructor (:33-37) stores the first two and does no I/O, which is what lets the check be registered without a reachable server.
    • CheckHealthAsync (:39) null-guards the context (:43), then resolves the multiplexer with _services.GetService<IConnectionMultiplexer>() ?? await GetOrCreateOwnedMultiplexerAsync() (:47-48). That single line is the two-mode design in the doc comment (:20-23): a host that wired Redis through AddRedisCaching() shares the container's multiplexer, and a host that configured a connection string without registering a client gets one this check owns.
    • It then checks cancellation explicitly (:50), issues multiplexer.GetDatabase().PingAsync() (:52), and returns Healthy with the latency formatted invariantly (:54-58).
    • Two catch clauses in a deliberate order. OperationCanceledException when the token was cancelled is rethrown (:60-63), so host shutdown is not reported as a dependency fault. Everything else is reported, never thrown (:64-72), with the comment stating the reason (:66-67): this check is tagged optional and must degrade the /health payload rather than fault the probe pipeline.
    • DisposeAsync (:75-84) disposes the owned multiplexer if one was created and always disposes the gate. GetOrCreateOwnedMultiplexerAsync (:90-101) does the connect under the semaphore with _ownedMultiplexer ??= await ConnectionMultiplexer.ConnectAsync(...) (:95). The comment above it (:86-89) is worth reading: there is no lock-free fast path on purpose, because probes run on a timer rather than the request path, and a failed connect leaves the field null so the next probe retries instead of latching a faulted result forever.
  • Why it's built this way: internal sealed framework code rather than a per-host registration, because ADR-025 makes the probe command itself a framework-owned decision: a host that reaches for an Aspire client integration directly puts the package's untagged check back and defeats the rule. IAsyncDisposable rather than a fire-and-forget connection, so the fallback multiplexer is built once and released with the container. PING returns a TimeSpan, which is why the healthy description can carry latency at no extra cost.
  • Where it's used: Registered inside AddInfrastructureHealthChecks only when a redis connection string is present (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:314-331): TryAddSingleton builds it once (:282-283) and the HealthCheckRegistration named "redis" resolves that singleton and tags it HealthCheckTags.Optional (:285-289), so a cache outage degrades /health without ever failing /health/ready. The Aspire client integrations' own checks are switched off at the source in RedisCachingExtensions (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Caching/RedisCachingExtensions.cs:64-65, DisableHealthChecks = true on both the distributed cache and the client) precisely so this one is the only Redis check in the pipeline. Covered by RedisPingHealthCheckTests, whose cases include CheckHealthAsync_UsesPingOnly_AndNeverAnAdministrativeCommand and CheckHealthAsync_WhenTheServerRefusesACommand_ReportsUnhealthyWithoutRethrowing, and by RedisReadinessSafetyTests for the tagging.
  • Caveats / not-in-source: PING proves reachability, not capacity: a cache that answers pings while evicting under memory pressure reports healthy. The owned-multiplexer path uses the raw connection string with no retry configuration of its own beyond StackExchange.Redis defaults.

ServiceBusEmulatorResource

MMCA.Common.Aspire.Hosting · MMCA.Common.Aspire.Hosting · MMCA.Common/Source/Hosting/MMCA.Common.Aspire.Hosting/ServiceBusEmulatorResource.cs:32 · Level 0 · class (sealed)

  • What it is: The Aspire resource type for the official Azure Service Bus emulator container, publishing both of the emulator's planes and exposing the two connection expressions a consuming service needs.
  • Depends on: Aspire.Hosting.ApplicationModel (ContainerResource, IResourceWithConnectionString, EndpointReference, ReferenceExpression, EndpointProperty). Provisioned by Extensions' AddServiceBusEmulatorBroker, and consumed by the WithBroker overload that takes it. The settings it feeds are MessageBusSettings / MessageBusProvider.
  • Concept introduced, environment parity as a first-class local resource. [Rubric §33, Developer Experience], [Rubric §17, DevOps], [Rubric §7, Microservices Readiness]. §33 assesses how closely the inner loop resembles production. RabbitMQ is fast and dependency-free but is a different broker with different topology semantics, so anything that only breaks on Azure Service Bus (entity naming limits, topic and subscription provisioning, scheduled redelivery) stays invisible locally until it reaches production. This resource closes that divergence for a stack that wants it. The class doc (ServiceBusEmulatorResource.cs:10-16) explains why two endpoints are non-negotiable: the emulator serves two planes on two protocols, AMQP for every publish and consume, and HTTP for the management plane that creates topics, subscriptions and queues. MassTransit provisions its own topology at bus start, so a broker with no admin plane is not merely limited, it is unusable. ADR-066, in its 2026-09-01 revision, is the record.
  • Concept introduced, a connection-string marker as a mode switch. [Rubric §15, Best Practices & Code Quality], [Rubric §11, Security]. ConnectionStringExpression ends in UseDevelopmentEmulator=true, and the doc comment (:18-24) says that suffix is not decoration: it keeps the Azure SDK clients on plain TCP and HTTP against a local host, and it is the marker MMCA.Common.Infrastructure detects to take its emulator configuration branch. A real Azure Service Bus connection string never carries it, which is what makes the emulator path impossible to enter by accident in production.
  • Walkthrough:
    • Four constants pin the wire contract: AmqpEndpointName = "amqp" (:35), AdminEndpointName = "admin" (:38), AmqpTargetPort = 5672 (:41) and AdminTargetPort = 5300 (:44).
    • The constructor (:48-53) calls base(name) and creates the two EndpointReferences against itself. The resource name doubles as the container's network alias (:47).
    • AmqpEndpoint (:56) and AdminEndpoint (:59) expose those references.
    • ConnectionStringExpression (:66-68) builds Endpoint=sb://{host}:{port};SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true; from the allocated AMQP endpoint's host and port properties. The key name and value are the emulator's own fixed placeholders because it authenticates nothing (:62-64).
    • AdminEndpointExpression (:80-82) builds {scheme}://{host}:{port} from the admin endpoint. The scheme is read off the allocated endpoint rather than written literally (:75-77), so the value tracks whatever the endpoint was declared as (http today: the management plane is cleartext by design and reachable only from the local development stack). A consumer hands this to MessageBus:EmulatorAdminEndpoint, which is what lets the infrastructure layer build the second administration client MassTransit v8 needs to provision topology (:71-74).
    • The last doc paragraph (:25-30) records a deliberate omission: no health check is declared. A WaitFor on this resource therefore gates on the container running, not on the emulator finishing its warm-up, and the consuming service absorbs the remainder because MassTransit starts its bus in the background and reconnects rather than failing host startup.
  • Why it's built this way: A dedicated resource type rather than a bare AddContainer because the two connection expressions are the reusable part, and IResourceWithConnectionString is what lets WithReference inject the AMQP string automatically. Both endpoint names and both container ports are public const so tests and consumers can assert them without string literals, which is exactly how ServiceBusEmulatorBrokerTests pins the published planes.
  • Where it's used: Constructed by AddServiceBusEmulatorBroker (MMCA.Common/Source/Hosting/MMCA.Common.Aspire.Hosting/Extensions.cs:209-238) and consumed by the WithBroker(IResourceBuilder<ServiceBusEmulatorResource>) overload (Extensions.cs:280-292). One consumer wires it today: ADC's AppHost picks the emulator over RabbitMQ when ADC_BROKER=servicebus (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:91-95), leaving RabbitMQ as the default inner loop (:96-101). Covered end to end by ServiceBusEmulatorBrokerTests (ConnectionString_IsTheEmulatorForm_BuiltFromTheAllocatedAmqpEndpoint, AdminEndpoint_IsBuiltFromTheAllocatedManagementEndpoint).
  • Caveats / not-in-source: The connection string is built from the allocated endpoint, so reading it before the application model has allocated ports is not meaningful. Nothing in this file constrains the emulator's own quota or TTL limits; ADR-066 records that MassTransit's process-global defaults are lowered under them by the test fixture, which is not this type's concern.

DownstreamServiceHealthCheck

MMCA.Common.Aspire · MMCA.Common.Aspire.Gateway · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Gateway/DownstreamServiceHealthCheck.cs:39 · Level 1 · class (sealed, internal)

  • What it is: One IHealthCheck that GETs a single downstream service's /alive endpoint through a service-discovery-resolved HttpClient, negotiating the HTTP version when told to, and reports the outcome as the gateway's own health.
  • Depends on: DownstreamProbeVersion (its version profile), IHttpClientFactory, IHealthCheck / HealthCheckResult / HealthCheckContext (ASP.NET Core diagnostics), and HttpRequestError / HttpVersionPolicy / Interlocked (BCL). The probed path comes from HealthEndpointPaths. Registered, named and budgeted by GatewayHealthCheckExtensions; its Ready tag comes from HealthCheckTags.
  • Concept introduced, dependency-aware readiness at the edge. [Rubric §13, Observability & Operability], [Rubric §29, Resilience & Business Continuity], [Rubric §7, Microservices Readiness]. §29 assesses whether a partial failure is contained rather than amplified. A reverse-proxy gateway whose readiness reports only "this process is up" is routed traffic by the platform and then forwards it to services that are not answering, converting a downstream outage into a wall of 502s from a replica the platform believes is healthy. This check is the correction: readiness becomes "can this edge do useful work". ADR-088 is the governing record.
  • Concept introduced, latching a negotiated protocol. [Rubric §12, Performance & Scalability], [Rubric §15, Best Practices & Code Quality]. The class doc (:22-30) argues the safety of the latch in one move: a service cannot change the protocol of its cleartext endpoint without a redeploy, and a redeploy of the topology restarts this gateway too, so a stale latch cannot outlive the endpoint that justified it. That is the reasoning pattern worth copying, a cache is safe when its invalidation is structurally guaranteed rather than scheduled.
  • Walkthrough:
    • A primary constructor takes the factory, the Aspire service name (used only in messages), the named-client registration to resolve, and the registration-time DownstreamProbeVersion (:39-43).
    • ProbePath is a static readonly Uri built from HealthEndpointPaths.Alive (:46), the shared /alive constant (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/HealthEndpointPaths.cs:14), so the probe and the endpoint that answers it cannot drift apart by a typo. It probes /alive, not /health/ready, deliberately (doc comment :16-21): a downstream replica's readiness is that replica's own business and its ingress already routes around a warming instance, so probing readiness would make every rolling deployment downstream register as a gateway health failure. What the gateway needs to know is whether the service exists and is reachable at all.
    • private int _latchedProbeVersion (:55) holds the settled version as the enum's underlying value, so zero (which is Auto) reads as "not settled yet". It is written with Interlocked.CompareExchange rather than a plain assignment because two readiness polls can overlap on a cold gateway: the first writer wins and nothing ever unlatches (:48-54).
    • CheckHealthAsync (:58) null-guards the context (:62), creates the named client (:64), then branches: Auto goes to NegotiateAsync (:68-71), a pinned mode goes straight to SendProbeAsync (:73).
    • The outer catch (:75-87) is narrow on purpose: HttpRequestException, TaskCanceledException, TimeoutException and InvalidOperationException (unreachable, DNS-unresolvable, or slower than the probe budget) are reported as context.Registration.FailureStatus with the exception attached, while the when (!cancellationToken.IsCancellationRequested) guard keeps host shutdown from being reported as a dependency fault. Reading the failure status from the registration rather than returning Unhealthy directly is what lets the same check be re-tagged by a future caller without touching this file. The comment (:78-82) adds the second rule: nothing latches on this path, because a transient outage says nothing about which protocol the endpoint speaks and pinning the wrong one would outlive the outage.
    • NegotiateAsync (:98) reads the latch with Volatile.Read and short-circuits to the settled version when there is one (:103-107). Otherwise it probes HTTP/2 and, on any HTTP response at all, latches Http2 (:111-117); the comment there (:114-115) draws the distinction that matters, any response proves the endpoint speaks HTTP/2, and the status code is the health verdict rather than the protocol verdict. Its catch is scoped to HttpRequestException when (IsProtocolRefusal(ex) && !cancellationToken.IsCancellationRequested) (:119) and retries once as HTTP/1.1 inside the same check and token budget (:125-129), so the poll still returns a verdict; if that also fails, the outer handler reports the failure status and nothing latches.
    • SendProbeAsync (:143) is where the version pair is applied, per request rather than on the client because one client serves both attempts of a negotiation (:151): Version = HttpVersion.Version11 with RequestVersionOrLower for HTTP/1.1, otherwise Version20 with RequestVersionExact (:152-158). A success status returns Healthy with the numeric code formatted invariantly (:164-169); anything else returns the registration's failure status with the same message shape (:171-174).
    • IsProtocolRefusal(exception) (:184-186) is the classifier and the whole safety of the fallback: only HttpRequestError.VersionNegotiationError and HttpProtocolError justify retrying at another version, because DNS, a refused connection, a TLS fault or a timeout say nothing about which protocol the endpoint speaks (:177-181).
    • Latch(version) (:192-196) is the single Interlocked.CompareExchange against Auto: first writer wins, a latched value is never replaced.
  • Why it's built this way: internal sealed, since it is only ever constructed by the registration extension in the same assembly. It is a hand-rolled check rather than a reference to the AspNetCore.HealthChecks.Uris package, and the class doc says why (:10-15): the whole check is one GET and a status comparison, and adding a package to a framework assembly that fifteen consumers restore is a real cost for no behaviour a dozen lines do not already give. The negotiate-then-latch shape means the fallback costs one extra request per downstream per process rather than one per poll.
  • Where it's used: Constructed inside the shared registration body (GatewayHealthCheckExtensions.cs:205-209), one instance per named service, which is what makes the latch effectively per downstream. Covered by GatewayDownstreamHealthChecksTests, whose negotiation cases pin every branch (Probe_WhenAutoAndHttp2Answers_LatchesHttp2AndStopsNegotiating, Probe_WhenAutoAndHttp2IsRefused_FallsBackToHttp11WithinTheSameCheck, Probe_WhenAutoHasFallenBack_GoesStraightToHttp11, Probe_WhenAutoAndTheDownstreamIsUnreachable_ReportsUnhealthyWithoutLatching), and end to end by GatewayHardeningTests against the booted ADC Gateway host.
  • Caveats / not-in-source: The check has no retry and no memory for health: one failed probe fails the gateway's readiness for that poll. That is intentional given the 2 second budget, but it means a downstream that answers slowly under load flaps the gateway's readiness rather than degrading it. The latch is per instance, so it is reset by a gateway restart and by nothing else.

Extensions

MMCA.Common.Aspire.Hosting · MMCA.Common.Aspire.Hosting · MMCA.Common/Source/Hosting/MMCA.Common.Aspire.Hosting/Extensions.cs:23 · Level 1 · class (static)

Disambiguation: this is the Common.Aspire.Hosting Extensions (the AppHost-side broker, mail, JWKS, E2E and data-source wiring). The other class named Extensions in this chapter is the framework's Common.Aspire Extensions (the canonical service-defaults bootstrap, service-side). Neither app ships a local ServiceDefaults project; their services consume the Common.Aspire one directly.

  • What it is: AppHost-side Aspire extension methods that wire shared local infrastructure (a RabbitMQ broker or the Azure Service Bus emulator, a MailDev SMTP sink), JWKS-based identity discovery, CI/E2E signing keys and limit lifts, and per-service database routing onto Aspire resources.
  • Depends on: ServiceBusEmulatorResource and Aspire.Hosting / Aspire.Hosting.RabbitMQ / Aspire.Hosting.Azure (IDistributedApplicationBuilder, IResourceBuilder<ContainerResource>, IResourceBuilder<RabbitMQServerResource>, IResourceBuilder<ProjectResource>, IResourceBuilder<SqlServerDatabaseResource>, AzureCosmosDBDatabaseResource, ReferenceExpression). Conceptually pairs with MessageBusProvider / MessageBusSettings (the env vars it sets), the JWKS auth side (IJwksProvider, RsaJwksProvider), the multi-source routing in DataSourceResolver / EntityDataSourceRegistry, and GatewayRateLimitingSettings (the section its gateway lift writes into).
  • Concept introduced, the AppHost tier is a separate package on purpose. [Rubric §7, Microservices Readiness], [Rubric §8, Data Architecture], [Rubric §17, DevOps], [Rubric §11, Security], [Rubric §32, Dependency & Supply-Chain]. This assembly is deliberately separate from MMCA.Common.Aspire (the service-defaults assembly every running service consumes) so that running services do not pull in the heavy Aspire.Hosting tooling package (doc comment Extensions.cs:13-17). The rule bites in a visible place: rather than referencing GatewayRateLimitingSettings.SectionName, this class mirrors the literal as its own constant, and the comment (:62-69) states both halves of the trade, that the AppHost tier must not pull in the service-defaults graph (Azure Monitor, OpenTelemetry, Key Vault) to spell one configuration key, and that a unit test cross-asserts the two are equal so a section rename cannot silently orphan the lift. That is the general answer to "a duplicated literal across a package boundary": duplicate it, then pin the duplication with a test. ADR-098 records the scope of Aspire adoption here (orchestration and service defaults, not a testing substrate). The class also carries a scoped CA1708 suppression (:19-22) documenting the known analyzer false positive: with two or more extension(T) blocks in one static class, the compiler-generated grouping members trip the "identifiers should differ by more than case" rule.
  • Walkthrough:
    • Constants. DefaultBrokerResourceName = "rabbitmq" (:28) and E2eRegistrationsPerIpPerHour = 1000 (:35), the latter described as "high enough that no suite can reach it" while production keeps the real anti-abuse throttle (:30-34). The E2E lift group adds E2eLiftTriggerVariable = "E2E_LIFT_REGISTRATION_THROTTLE" (:42, the single trigger both lifts read), E2eGatewayPermitLimit = 100000 (:48), E2eGatewayGlobalConcurrencyLimit = 10000 (:54) and E2eAuthTightPermitLimit = 100000 (:60), plus the three configuration keys they compose from: GatewayRateLimitingSection (:70), MmcaGatewaySection = "MmcaGateway" (:77) and AuthTightPolicyName = "auth-tight" (:84, the one part with no framework type to derive it from, because a route policy name is app configuration). The emulator group pins DefaultServiceBusEmulatorResourceName = "servicebus" (:89), ServiceBusEmulatorImageRegistry = "mcr.microsoft.com" (:94), ServiceBusEmulatorImage (:99) and ServiceBusEmulatorImageTag = "2.0.1" (:107), the tag comment (:101-106) recording that 2.x is a floor rather than a preference: the HTTP management plane shipped in 2.0.0, so a silent downgrade to a 1.x image leaves the broker unusable rather than merely older. The mail group fixes DefaultMailDevResourceName = "maildev" (:112), MailDevHttpPort = 1080 (:118) and MailDevSmtpPort = 1025 (:124).
    • AddMailDev(name) (:141, in an extension(IDistributedApplicationBuilder builder) block at :126): AddContainer(name, "maildev/maildev") with a persistent lifetime and two published endpoints (:146-149). Both host ports are fixed rather than dynamic (:132-137), because the web UI is opened by hand and the SMTP port is what each consumer's Smtp:Port setting names; the persistent lifetime is what lets the captured inbox survive an AppHost restart.
    • AddMessageBroker(name) (:160): builder.AddRabbitMQ(name).WithManagementPlugin() (:164), so one call provisions the broker container with its management UI. Production overrides the connection string via configuration so the same projects can target Azure Service Bus without an AppHost change (:152-156). See ADR-066.
    • AddServiceBusEmulatorBroker(sqlServer, name) (:201): the parity option. It constructs a ServiceBusEmulatorResource (:209) and chains WithImage before WithImageRegistry, with the comment explaining the ordering (:212-213): the registry call updates the image annotation the image call creates, and throws when there is none yet. It then publishes the AMQP endpoint as tcp and the admin endpoint as HTTP (:216-222), sets ACCEPT_EULA=Y (:227, with the comment noting the image is only ever used for local development and test), passes the existing SQL Server's host and SA password as SQL_SERVER / MSSQL_SA_PASSWORD (:228-233), and finishes with .WaitFor(sqlServer) (:237) because the emulator's first act is to create its schema. The doc comment (:167-193) carries the design: the emulator stores its state in SQL Server, so it reuses the stack's engine rather than starting a second one, and host ports are left to Aspire because nothing outside the stack dials them.
    • WithBroker<TResource>(broker), RabbitMQ overload (:252, in a generic extension<TResource>(IResourceBuilder<TResource> service) where TResource : IResourceWithEnvironment, IResourceWithWaitSupport block at :241-242): .WithReference(broker) + .WaitFor(broker) + .WithEnvironment("MessageBus__Provider", "RabbitMq") (:258-261). One fluent step wires the reference, holds the service until the broker is healthy, and selects the transport. The generic constraint statically restricts the method to capable resources.
    • WithBroker<TResource>(broker), emulator overload (:280): the same three moves plus two more env vars, MessageBus__ConnectionString from the resource's emulator-form connection string and MessageBus__EmulatorAdminEndpoint from its management-plane expression (:286-291). The doc comment (:271-276) restates the mode-switch rule: the connection string carries UseDevelopmentEmulator=true, which is the marker AddBrokerMessaging keys its emulator branch off, and a production connection string never carries it.
    • WithJwksDiscovery<TResource>(identity, gateway?) (:309): .WithReference(identity) + .WaitFor(identity) (:317-318), then a deferred .WithEnvironment(context => ...) (:319) that sets Authentication__JwtBearer__Authority (:335). It prefers the gateway HTTPS endpoint over Identity's (:332-334) because, per the inline comment (:321-331), Identity listens HTTP/2-only on cleartext for gRPC h2c, but the default JwtBearer backchannel HttpClient sends HTTP/1.1, which Kestrel rejects on an Http2-only endpoint. The gateway terminates TLS, supports HTTP/1.1 and HTTP/2 via ALPN, and forwards /.well-known/* to Identity, so the metadata fetch works end to end. With no gateway passed it falls back to Identity's HTTPS endpoint, with the HTTP/1.1 caveat. See ADR-004, ADR-008 and ADR-012.
    • WithE2eRsaKeys() (:353, in an extension(IResourceBuilder<ProjectResource> identity) block at :340): a CI/E2E-only helper that reads E2E_JWT_PRIVATE_KEY_PEM / E2E_JWT_PUBLIC_KEY_PEM from the AppHost's own environment (:357-358), returns the builder unchanged when either is missing (:359-362), and otherwise maps them onto Jwt__RsaPrivateKeyPem, Jwt__RsaPublicKeyPem and Jwks__RsaPublicKeyPem (:364-367). The doc comment (:342-350) records the failure it prevents: without the forwarding, every CI login and register fails with "No supported key formats were found" and the readiness gate times out. It is a no-op locally and in production, where the variables are absent and user-secrets or Key Vault supply the keys.
    • WithE2eRegistrationThrottleLift(alsoLiftWhen = false) (:392): computes lift as the argument OR-ed with an ordinal-ignore-case comparison of E2eLiftTriggerVariable against "true" (:396-400), and when lifted sets LoginProtection__MaxRegistrationsPerIpPerHour to E2eRegistrationsPerIpPerHour formatted invariantly (:402-406); otherwise it returns the builder untouched. The doc comment (:370-390) states the problem exactly: production allows ten registrations per IP per hour, an E2E suite registers far more than that from a single localhost IP, so the production default refuses every register test past the tenth and the failures look like broken registration rather than the anti-abuse control doing its job.
    • WithE2eGatewayRateLimitLift(alsoLiftWhen = false) (:440, in an extension(IResourceBuilder<ProjectResource> service) block at :410): the same trigger shape (:444-448) applied to the edge limiter. When lifted it writes three overrides (:455-464): GatewayRateLimiting__PermitLimit, GatewayRateLimiting__GlobalConcurrencyLimit, and the per-route MmcaGateway__RateLimiterPolicies__auth-tight__PermitLimit, all composed from the mirrored section constants rather than hard-coded strings. The doc comment (:412-431) is worth reading for the diagnosis: the whole suite arrives from one loopback client IP, so the per-IP window that protects production reads the suite itself as the flood (it names ADC run 32185349945 and its twelve login failures), and the named anti-credential-stuffing policy has the same problem for the same reason. It closes with the operational warning that a regression here surfaces as login and register E2E reds, never as a build failure.
    • WithSQLServerDataSource(database, logicalName) (:483): the AppHost manifestation of ADR-006. One fluent chain: .WithReference(database), .WaitFor(database), then a single .WithEnvironment setting DataSources__{logicalName}__SQLServerConnectionString (:490-493), which the double-underscore convention flattens to DataSources:{logicalName}:SQLServerConnectionString for the multi-source routing layer. The doc comment (:473-478) is explicit that that one entry is the whole configuration: with no top-level connection string, the single database a host declares this way also becomes its Default source, so the framework's own tables and the readiness health check both land on it and the logical name collapses onto Default, giving one context, one change tracker and one migration set per deployed service.
    • WithCosmosDataSource(database, logicalName) (:512): the Azure Cosmos DB sibling (ADR-018). .WithReference + .WaitFor + two env vars (:519-523): the account connection string the resolver hands to CosmosDbContext.UseCosmos(...), and CosmosDatabaseName, since UseCosmos takes the database name separately. Unlike SQL Server, a service typically uses Cosmos for one module alongside its SQL Server source, so this is layered on top of rather than instead of WithSQLServerDataSource (:506-507).
    • WithSqliteDataSource(logicalName, filePath) (:537): the SQLite sibling. SQLite has no Aspire container resource (it is an in-process file), so this only injects DataSources__{logicalName}__SqliteConnectionString built as Data Source=<path> (:544-547). Note the different signature, a filePath string instead of a database resource.
  • Why it's built this way: Fluent extensions on IResourceBuilder<T> match the Aspire AppHost idiom; the assembly split keeps service runtimes free of AppHost-only dependencies; the optional gateway? lets a monolith deployment still use JWKS by pointing straight at Identity. The three With*DataSource helpers share one naming shape so an engine move is a one-line AppHost change, and making logicalName an explicit parameter keeps each service's database identity discoverable by reading the AppHost. The three WithE2e* helpers are the same idea applied to test infrastructure: the knowledge that CI needs a keypair forwarded and two limiters lifted lives in the framework rather than being copied into each app's AppHost, all three share one trigger variable, and all three are inert unless it is set.
  • Where it's used: The AppHost Program.cs of every consumer. ADC selects its broker at startup and captures the choice as a function (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:89-101: AddServiceBusEmulatorBroker(sqlServer) when ADC_BROKER=servicebus, otherwise AddMessageBroker() with a persistent lifetime), adds MailDev once (:109), calls WithSQLServerDataSource per service (:130, :159, :202, :230), wires JWKS discovery for the three non-Identity services through the gateway (:364-366), forwards E2E keys to Identity (:248), and applies both lifts (:428 on Identity, :435 on the gateway, each with alsoLiftWhen: forceWasm). Store does the same for its three services: broker at MMCA.Store/Source/Hosting/MMCA.Store.AppHost/Program.cs:48, MailDev at :55, WithSQLServerDataSource at :128, :150, :191, WithBroker(rabbit) at :130, :152, :194, WithE2eRsaKeys() at :168, WithJwksDiscovery at :301-302, and both lifts with no call-site flag (:311, :320), so the environment variable is their only trigger. The two seed apps use the data-source helper on their single host: MMCA.Helpdesk/Source/Hosting/MMCA.Helpdesk.AppHost/Program.cs:29 and MMCA.ECommerce/Source/Hosting/MMCA.ECommerce.AppHost/Program.cs:20-21 (two logical sources on one host). The WithCosmosDataSource and WithSqliteDataSource helpers are available framework plumbing but no consumer wires them today: every ADC and Store service currently runs on SQL Server. The E2E lifts are covered by E2eLiftTests, including WithE2eGatewayRateLimitLift_EmitsKeysDerivedFromTheCommonOwnedSectionNames (the cross-assertion that keeps the mirrored constants honest) and WithE2eRegistrationThrottleLift_SharesTheGatewayLiftsTrigger; the emulator path by ServiceBusEmulatorBrokerTests.
  • Caveats / not-in-source: For all three With*DataSource helpers the logicalName must match the key the owning entity's configuration derives (module namespace or [UseDatabase]); source shows no validation of that string, so a mismatch routes that entity to the Default source rather than failing at startup. Several consumer AppHost comments still describe WithSQLServerDataSource as injecting a ConnectionStrings__ variable as well (for example MMCA.Helpdesk/Source/Hosting/MMCA.Helpdesk.AppHost/Program.cs:10); the current implementation sets only the DataSources__ key and relies on the resolver's collapse-onto-Default behaviour.

GatewayDownstreamHealthCheckOptions

MMCA.Common.Aspire · MMCA.Common.Aspire.Gateway · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Gateway/GatewayHealthCheckExtensions.cs:40 · Level 1 · class (sealed)

  • What it is: The per-call options object for AddGatewayDownstreamHealthChecks. It carries exactly one setting today, the probe's HTTP version profile.
  • Depends on: DownstreamProbeVersion. Consumed by GatewayHealthCheckExtensions.
  • Concept introduced, per-call options instead of a per-name map. [Rubric §9, API & Contract Design], [Rubric §15, Best Practices & Code Quality]. The class doc (:35-39) states the shape decision plainly: one instance covers every service name passed to that call, so a gateway that wants to pin a profile for some of its heads makes one call per profile rather than carrying a per-name dictionary. That keeps the common case (one call, all downstreams, default options) a single readable line, and makes the uncommon case cost a second call rather than a new data structure. The long remarks on ProbeVersion (:45-57) are the same negotiation argument taught at DownstreamProbeVersion, stated from the caller's side.
  • Walkthrough: One member. ProbeVersion (:59) is a get/set property defaulting to DownstreamProbeVersion.Auto, which the doc describes as the value that "settles the question per downstream and needs no per-service configuration". Pinning either fixed value is documented as an optimisation for a downstream whose profile is known and fixed, to skip the one-time negotiation (:54-57).
  • Why it's built this way: Mutable set rather than init, because the consumption pattern is the standard Action<TOptions> configurator (GatewayHealthCheckExtensions.cs:148-151), which needs to write to an instance the framework created. The registration body immediately reads the value into a local before the closure captures anything (:172-174), with the comment giving the reason: the registration must not hold the mutable options object, or a later call reusing the same instance would retro-change checks that are already registered. That pairing (mutable options, eagerly copied at registration) is worth noticing, it is how a configurator API stays convenient without leaking mutability into runtime state.
  • Where it's used: Constructed and configured inside Register (:169-170), which both public overloads funnel into. No consumer passes a configurator today: ADC and Store both call the params-only overload. Covered by GatewayDownstreamHealthChecksTests (Options_DefaultToAutoNegotiation, AddGatewayDownstreamHealthChecks_WithOptions_RegistersTheSameChecks, and ProbeVersion_IsScopedToItsOwnRegistrationCall, which is the test that pins the eager copy).

GatewayRateLimitingExtensions

MMCA.Common.Aspire · MMCA.Common.Aspire.Gateway · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Gateway/GatewayRateLimitingExtensions.cs:39 · Level 1 · class (static)

  • What it is: The edge rate limiter: a per-client-IP fixed window chained with a process-wide concurrency ceiling, registered by AddGatewayRateLimiting and inserted into the pipeline by UseGatewayRateLimiting.
  • Depends on: GatewayRateLimitingSettings (its configuration), plus System.Threading.RateLimiting (PartitionedRateLimiter, RateLimitPartition, FixedWindowRateLimiterOptions, ConcurrencyLimiterOptions), CryptographicOperations (BCL cryptography), IServiceCollection / IApplicationBuilder, and Validator (BCL data annotations).
  • Concept introduced, two limiters that answer two different questions. [Rubric §11, Security], [Rubric §12, Performance & Scalability], [Rubric §29, Resilience]. The class doc (:13-33) states both halves. First, the edge posture is the deliberate opposite of the service tier's: AddCommonRateLimiting in MMCA.Common.API partitions by authenticated user and exempts anonymous traffic, but at the edge there is no authenticated identity yet (the gateway forwards the token, it does not validate it into a principal) and anonymous traffic is precisely what has to be bounded, so the partition key is the client IP and anonymity is not an exemption. Second, the two limiters are chained rather than merged because a fixed window bounds how fast one caller may arrive, while a concurrency limiter bounds how much work the replica holds at once regardless of who sent it; a request must satisfy both, and the second failure is one a per-IP window structurally cannot see. ADR-088 extends ADR-019 with this fourth tier.
  • Concept introduced, a constant-time secret comparison at the edge. [Rubric §11, Security], [Rubric §14, Testability]. IsSyntheticTraffic is small but is the only place in this chapter that handles a secret, and its remarks (:86-93) name three separate defences that are easy to get wrong individually: the comparison runs over UTF-8 bytes through CryptographicOperations.FixedTimeEquals, which is constant time for equal-length inputs and returns false for unequal lengths without leaking anything past the length an attacker already chose; exactly one header value is required, so a caller cannot spray candidates in a multi-valued header; and the whole path is short-circuited when no secret is configured, so the bypass cannot be claimed by presenting the header alone.
  • Walkthrough:
    • Three partition-key constants (:42, :45, :48): "__bypass" for anything exempt, "__unknown-ip" for an unattributable caller, and "__gateway", the single key the concurrency limiter counts everything against.
    • AlwaysBypassedPrefixes = ["/health", "/alive", "/.well-known"] (:55). The comment above it (:50-54) is the reason these are hard-coded rather than configured: probes and JWKS discovery run at high frequency by design, and throttling them converts a traffic spike into a failed probe and a container restart.
    • IsBypassed(path, settings) (:67) concatenates the hard-coded prefixes with the host's configured BypassPathPrefixes and matches with PathString.StartsWithSegments(..., OrdinalIgnoreCase) (:72-75), skipping blank entries. Segment matching is the detail: /healthz is not matched by /health. It is internal rather than private so the matcher is unit-testable (:66).
    • IsSyntheticTraffic(httpContext, settings) (:94) returns false immediately when no secret is configured (:99-102), then requires exactly one header value under the configured name (:104-108), and finishes with CryptographicOperations.FixedTimeEquals over the UTF-8 bytes of both sides (:110-113).
    • ClientIpPartition(httpContext, settings) (:124) returns RateLimitPartition.GetNoLimiter(BypassPartitionKey) when either exemption applies (:131-134); returns GetNoLimiter(UnknownIpPartitionKey) when Connection.RemoteIpAddress is null (:136-142), a fail-open choice whose comment (:139-140) explains the alternative it rejects, that collapsing every unattributable request into one shared bucket would throttle an in-process TestServer and the integration tier to a standstill. Otherwise it returns a fixed-window limiter keyed on the IP string, with PermitLimit and Window from settings and QueueLimit = 0 (:144-151), so overage is rejected immediately rather than queued.
    • ConcurrencyPartition(httpContext, settings) (:162) applies the same two-part bypass test and otherwise returns one concurrency limiter on the single "__gateway" key with GlobalConcurrencyLimit permits and, again, QueueLimit = 0 (:169-176).
    • AddGatewayRateLimiting(IConfiguration configuration) (:188, in an extension(IServiceCollection services) block at :179) binds the "GatewayRateLimiting" section through AddOptions().Bind(section).ValidateDataAnnotations().ValidateOnStart() (:193-197), then delegates to the object overload with an eagerly-bound copy, falling back to defaults when the section is absent (:199-200).
    • AddGatewayRateLimiting(GatewayRateLimitingSettings settings) (:210) is the overload every path funnels into. It runs Validator.ValidateObject(settings, ..., validateAllProperties: true) at registration (:218), with the reasoning inline (:215-217): the limiter closes over this settings instance rather than resolving IOptions per request, so an out-of-range value (or a synthetic secret under 32 characters) has to throw where it is configured, not at the first throttle. It then calls AddRateLimiter (:220), sets RejectionStatusCode to 429 (:222), and assigns options.GlobalLimiter = PartitionedRateLimiter.CreateChained(...) over the two partition functions (:227-231). Assignment rather than AddPolicy is deliberate (comment :224-226): calling the method twice replaces the limiter instead of throwing on a duplicate policy name.
    • UseGatewayRateLimiting() (:244, in an extension(IApplicationBuilder app) block at :236) null-guards and returns app.UseRateLimiter() (:246-247). Its doc comment (:238-242) carries the ordering contract: after UseForwardedHeaders() so the client IP is the caller's rather than the ingress's, and before the proxy is mapped.
  • Why it's built this way: Pure static partition functions with internal visibility make the three decisions that actually contain the policy (which paths are exempt, which requests prove the secret, which partition a request lands in) assertable without a running server, which is [Rubric §14, Testability] applied at the smallest useful granularity. Validating in the object overload rather than only through the options pipeline closes the second way in. And the in-memory, per-replica design is accepted rather than solved (see GatewayRateLimitingSettings): an approximate ceiling that needs no network call and cannot fail is the right shape for the one process the whole fleet sits behind. The class carries the same scoped CA1708 suppression as its siblings (:35-38).
  • Where it's used: Registered in both gateways from configuration (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:64, MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:87) and inserted into the pipeline after CORS and before the proxy is mapped (ADC Program.cs:144, Store Program.cs:170). Covered by GatewayRateLimitingTests and end to end by GatewayHardeningTests in both apps (MMCA.ADC/Tests/Hosts/MMCA.ADC.Gateway.Tests/GatewayHardeningTests.cs:29, MMCA.Store/Tests/Hosts/MMCA.Store.Gateway.Tests/GatewayHardeningTests.cs:32). The E2E lift that raises these limits for a CI run lives in the AppHost tier (Extensions' WithE2eGatewayRateLimitLift).
  • Caveats / not-in-source: The limiter closes over an eagerly-bound settings copy, so a configuration reload does not change the live limits: a change takes a restart. The synthetic-traffic bypass is a shared secret with no rotation mechanism in source; rotating it means redeploying the gateway with a new Key Vault value.

H2cEndpointHealthCheck

MMCA.Common.Aspire.Hosting · MMCA.Common.Aspire.Hosting · MMCA.Common/Source/Hosting/MMCA.Common.Aspire.Hosting/H2cEndpointHealthCheck.cs:23 · Level 1 · class (sealed, internal)

  • What it is: An AppHost-side IHealthCheck that GETs one path on an Aspire endpoint over HTTP/2 with prior knowledge (h2c) and reports the outcome as that resource's health.
  • Depends on: Aspire.Hosting.ApplicationModel (EndpointReference), IHealthCheck / HealthCheckResult (ASP.NET Core diagnostics), and SocketsHttpHandler / HttpClient / HttpVersionPolicy (BCL). Registered by H2cHealthCheckExtensions, whose ProbeTimeout it reuses. It is the AppHost-tier analogue of DownstreamServiceHealthCheck, sharing the same version pair.
  • Concept introduced, gating a WaitFor on a protocol the resource actually speaks. [Rubric §29, Resilience & Business Continuity], [Rubric §33, Developer Experience], [Rubric §13, Observability & Operability]. Aspire's stock HTTP health check probes with a default HttpClient, so the request goes out HTTP/1.1 and an Http2-only cleartext endpoint refuses it with GOAWAY HTTP_1_1_REQUIRED (doc comment :11-16). The check therefore never turns healthy, the resource cannot be health-gated at all, and every WaitFor edge into it degrades to "the process started". The version pair below is the whole difference.
  • Concept introduced, a keep-alive ping as a correctness control rather than tuning. [Rubric §29, Resilience], [Rubric §12, Performance & Scalability]. This is the most instructive comment in the chapter (:25-45) and it describes a real wedge. The client is shared for the process lifetime and HTTP/2 pools one connection per origin. Aspire's endpoint proxy listens before the target Kestrel does, so the first probe of a starting resource can open a connection that is accepted and then never answered: the TCP connect succeeds, the HTTP/2 handshake never completes, and the proxy holds that socket open for the life of the AppHost. Without pings, every later probe queues onto that one zombie connection and times out forever, even after the service is up, so the resource can never turn healthy and every WaitFor edge into it wedges the whole stack (the comment cites the 2026-08-31 apphost-smoke wedge and nightly runs 33392642362 / 33399413579). The pings bound the zombie's life instead: a connection that stops answering pings is torn down and the next probe opens a fresh one. The comment also records the fix that does not work and why, ConnectTimeout does not cover this case because the connect itself succeeds.
  • Walkthrough:
    • ProbeHandler (:46-52) is a static readonly SocketsHttpHandler with UseProxy = false (:48, the target is a sibling process on the developer's own machine or a sibling replica inside the deployment network) and the ping trio: KeepAlivePingDelay and KeepAlivePingTimeout of one second each (:49-50) with HttpKeepAlivePingPolicy.Always (:51).
    • ProbeClient (:59-60) is one process-lifetime HttpClient over that handler, constructed with disposeHandler: false and Timeout = H2cHealthCheckExtensions.ProbeTimeout. Static rather than per-registration so a handful of gated resources do not each hold their own connection pool, and so this type owns no disposable instance state (:54-58).
    • Three readonly fields (:62-64): a Func<string> URL resolver, the path, and a send delegate. The production constructor (:71-74) takes an EndpointReference and passes () => endpoint.Url plus ProbeClient.SendAsync. The endpoint URL is resolved on every check, not captured at registration (:18-21), because the AppHost registers this check while building the application model and Aspire only allocates the endpoint once the resource starts.
    • The second constructor (:88-96) is the test one, taking the resolver and the send delegate directly. Its doc comment (:76-83) explains the choice of a send delegate over an HttpMessageHandler: it keeps this type free of a disposable instance field, since the shared client is process-lifetime state and a per-instance client would make every registration an owner of one.
    • CheckHealthAsync (:99) resolves the URL first, inside its own try (:106-120). An InvalidOperationException there means the endpoint has no allocation yet, which is the normal state for the first polls after the AppHost starts, and it returns the registration's failure status rather than Healthy and rather than letting the exception escape. The comment (:112-115) states the invariant: Aspire releases a wait only on Healthy, so a pre-allocation poll must not pass.
    • It then issues the GET (:124-135) with Version = HttpVersion.Version20 and VersionPolicy = HttpVersionPolicy.RequestVersionExact (:131-132), the pair whose comment (:128-130) restates the h2c rule: with no TLS there is no ALPN to negotiate with, so the request has to go out as HTTP/2 and must not be allowed to fall back. Success returns Healthy with a description naming the status and the protocol (:137-142); any other status returns the registration's failure status with the same description (:145).
    • The catch (:147-161) is narrow (HttpRequestException, TaskCanceledException, TimeoutException, InvalidOperationException, UriFormatException) so a genuine programming error still surfaces. It carries no cancellation guard, and the comment (:149-156) explains that this is the considered choice rather than an omission: the registration's own timeout cancels the same token this method receives, so a probe that outlives ProbeTimeout used to slip past a guard and be logged by the health service as an unhandled exception rather than reported here. The other canceller is AppHost shutdown, where an Unhealthy result goes nowhere and costs nothing.
  • Why it's built this way: internal sealed with two constructors, one for production and one that injects both collaborators, which is what lets the tests exercise the pre-allocation branch and the timeout branch without a running AppHost. The shared static client is the deliberate counterpart of that: process-lifetime state kept out of instance fields.
  • Where it's used: Constructed by WithH2cHealthCheck inside its HealthCheckRegistration factory (H2cHealthCheckExtensions.cs:130). Covered by H2cHealthCheckExtensionsTests, whose cases pin the version pair (Probe_SendsHttp2WithPriorKnowledge), the ping guard (ProbeHandler_KeepsTheKeepAlivePingGuardAgainstPoisonedConnections), the pre-allocation branch (Probe_WhenTheEndpointIsNotAllocatedYet_ReportsUnhealthyWithoutThrowing) and the cancelled-token branch (Probe_WhenTheRegistrationTimeoutHasCancelledTheToken_StillReportsUnhealthyRatherThanThrowing).
  • Caveats / not-in-source: The static handler and client are shared across every gated resource in the AppHost process, so the ping settings are global rather than per resource. This type is AppHost-only: nothing in a deployed service uses it, and the platform's own probes go through the dedicated Http1 listener described at KestrelEndpointExtensions.

H2cHealthCheckExtensions

MMCA.Common.Aspire.Hosting · MMCA.Common.Aspire.Hosting · MMCA.Common/Source/Hosting/MMCA.Common.Aspire.Hosting/H2cHealthCheckExtensions.cs:41 · Level 1 · class (static)

  • What it is: One call, WithH2cHealthCheck(path, endpointName), that registers an HTTP/2 health check against a project resource's endpoint and associates it with the resource, so WaitFor edges into that resource wait for a real answer instead of merely for process start.
  • Depends on: H2cEndpointHealthCheck (the check it constructs), H2cHealthCheckRegistry (the de-duplication ledger), plus Aspire.Hosting (IResourceBuilder<ProjectResource>, EndpointReference, WithHealthCheck) and HealthCheckRegistration (ASP.NET Core).
  • Concept introduced, a startup gate must probe liveness, never readiness. [Rubric §29, Resilience & Business Continuity], [Rubric §33, Developer Experience], [Rubric §13, Observability & Operability]. This is the rule to carry away from the whole chapter, and the doc comment on DefaultProbePath (:43-52) argues it as a cycle rather than a preference. A readiness endpoint aggregates downstream and warm-up checks, so gating startup on it can deadlock the dependency graph: the gateway waits for a service to report ready, that service's readiness includes a warm-up task that fetches through the gateway, and neither side can complete first. Liveness answers as soon as the process can serve a request, which is exactly what a dependent resource needs to know before it starts. ADR-012's 2026-08-29 update records the same reasoning and notes that the mixed-endpoint hosts' stock gates moved to /alive for the same reason, leaving only the UI resources (which nothing waits for) gated on /health/ready.
  • Concept introduced, recording a rejected alternative in the code that replaced it. [Rubric §34, Architecture Governance & Documentation], [Rubric §15, Best Practices & Code Quality]. The class doc's second half (:24-35) exists so one specific idea is not re-proposed: surfacing the deployed HTTP/1.1 health-probe listener as an Aspire endpoint and pointing a stock check at it. Injecting HealthProbe__Port locally flips KestrelEndpointExtensions out of endpoint-defaults mode and into explicit-listener mode, whose Listen calls then override the ASPNETCORE_URLS binding Aspire injects, so the service stops listening on the dynamic port Aspire allocated and every co-hosted service collides on one fixed cleartext port. The probe listener is a deployment-only construct; locally, the answer is to speak the protocol the service already serves.
  • Walkthrough:
    • DefaultProbePath = "/alive" (:53) and DefaultEndpointName = "http" (:59, the cleartext endpoint is the h2c one, since an https endpoint negotiates the version through ALPN and needs nothing special).
    • ProbeTimeout = TimeSpan.FromSeconds(2) (:66), matching the gateway's downstream budget, and short for the same reason (:61-65): Aspire polls this check for as long as a dependent resource is waiting, and a probe slower than the poll interval turns the wait into a queue.
    • CheckKey(resourceName, endpointName) (:75-76) composes {resource}-h2c-{endpoint}. The endpoint name is part of the key because a resource may expose more than one endpoint worth gating (:68-71).
    • WithH2cHealthCheck(path = DefaultProbePath, endpointName = DefaultEndpointName) (:110, in an extension(IResourceBuilder<ProjectResource> builder) block at :78) guards the builder and rejects blank arguments (:114-116), reaches the AppHost's own service collection (:118), builds the key (:119), and returns the builder unchanged when the ledger has already claimed it (:121-124), which is what makes a second call a no-op rather than a duplicate-key startup exception.
    • It then resolves the endpoint with builder.GetEndpoint(endpointName) (:126). That resolution is lazy by design (:103-107): an endpoint name the resource never declares is not an error here, it surfaces as a permanently unhealthy check, which keeps the dependent resource waiting instead of letting it start against a service nobody verified.
    • The registration (:128-133) names the key, constructs an H2cEndpointHealthCheck over that endpoint and path, sets failureStatus: HealthStatus.Unhealthy, passes tags: null (AppHost-side checks are not on a service's /health endpoints, so the HealthCheckTags vocabulary does not apply), and applies ProbeTimeout.
    • The final return builder.WithHealthCheck(key) (:137) is the half that is easy to miss, and the comment says so (:135-136): WithHealthCheck only associates the named check with the resource, while the registration above is what supplies it, and both halves are required for a WaitFor edge to gate.
  • Why it's built this way: An extension on IResourceBuilder<ProjectResource> so it chains into the existing AppHost fluent wiring, with defaults chosen so the common call is .WithH2cHealthCheck() with no arguments. Idempotence is engineered rather than assumed (see H2cHealthCheckRegistry). The doc comment is explicit that this replaces the stock extension only where it must: for an endpoint that serves Http1AndHttp2, Aspire's WithHttpHealthCheck is fine and this one is unnecessary (:84-88). The class carries the same scoped CA1708 suppression as its siblings (:37-40).
  • Where it's used: On every Profile A resource in both AppHosts, always with no arguments. ADC gates Engagement (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:166), Conference (:213) and Identity (:242), the last with the comment noting it is the edge that matters most because three services and the gateway wait for Identity (:239-241); the AppHost's own narrative block repeats the rationale (:314-321). Store gates Catalog (MMCA.Store/Source/Hosting/MMCA.Store.AppHost/Program.cs:134) and Identity (:159), with the choice explained at :108 and its consequence for the gateway's WaitFor edges at :279. Covered by H2cHealthCheckExtensionsTests, whose cases pin the defaults, the association, the registration, idempotence, per-endpoint keying, cross-builder isolation, the lazy endpoint resolution and both argument guards.
  • Caveats / not-in-source: This is AppHost-only wiring, so it affects local and CI runs rather than a deployed environment: ADR-012 records that production probes keep targeting the dedicated Http1-only listener. Nothing here validates that the probed path exists on the resource; a wrong path produces a permanently unhealthy check, which by design keeps dependents waiting.

GatewayHealthCheckExtensions

MMCA.Common.Aspire · MMCA.Common.Aspire.Gateway · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Gateway/GatewayHealthCheckExtensions.cs:70 · Level 2 · class (static)

  • What it is: Two overloads of AddGatewayDownstreamHealthChecks that register a downstream-{name} readiness check and a matching named HttpClient for each service the gateway fronts, one taking just the names and one taking a per-call options configurator.
  • Depends on: DownstreamServiceHealthCheck (the check it constructs), GatewayDownstreamHealthCheckOptions / DownstreamProbeVersion (the version profile), GatewayDownstreamRegistry (the de-duplication ledger), HealthCheckTags (the Ready tag), plus IServiceCollection, IHttpClientFactory and HealthCheckRegistration (ASP.NET Core). It relies on the service discovery that Common.Aspire Extensions' AddServiceDefaults() wires onto every client.
  • Concept introduced, service discovery as the address book for a health probe. [Rubric §7, Microservices Readiness], [Rubric §13, Observability & Operability], [Rubric §29, Resilience]. Nothing here hard-codes a host or a port: the client's BaseAddress is the literal http://{name}, the Aspire service-discovery form, which the resolver rewrites into the real endpoint at request time (comment :191-193). That is what lets the same registration work unchanged against a local AppHost with dynamic ports and against Container Apps. The second idea is the tag choice, argued in two parts in the remarks (:113-128). Tagging Ready puts the check on /health and /health/ready but keeps it off /alive, because a gateway that cannot reach its services should be pulled out of the load balancer while restarting the gateway process fixes nothing about a downstream outage. And the failure status is Unhealthy, not Degraded, because /health/ready treats Degraded as passing, so a Degraded downstream check would report a problem while still taking traffic the gateway cannot serve; a downstream the gateway can genuinely live without belongs behind Optional instead. ADR-088 is the record.
  • Walkthrough:
    • Shared vocabulary: CheckNamePrefix = "downstream-" (:73), ClientNamePrefix = "gateway-downstream-" (:76), and the helpers CheckName(serviceName) (:89) and ClientName(serviceName) (:94) that apply them. All internal, so the tests can assert the exact registered names.
    • ProbeTimeout = TimeSpan.FromSeconds(2) (:84). Its doc comment (:78-83) is the reasoning worth keeping: this runs on every /health/ready poll, once per downstream, and "a probe that takes longer than the poll interval turns readiness into a queue"; a service that cannot answer a liveness ping in two seconds is not one the gateway should be routing to anyway.
    • Two public overloads sit in one extension(IServiceCollection services) block (:96). AddGatewayDownstreamHealthChecks(params string[] serviceNames) (:130) and AddGatewayDownstreamHealthChecks(Action<GatewayDownstreamHealthCheckOptions>? configure, params string[] serviceNames) (:148) both delegate to the private Register (:131, :151), so there is exactly one registration body and the two entry points cannot drift.
    • Register(services, configure, serviceNames) (:161) null-guards both collections (:166-167), builds the options and applies the configurator (:169-170), then copies options.ProbeVersion into a local (:174) with the comment stating why (:172-173): the registration closure must not hold the mutable options object, because a later call reusing the same instance would retro-change these checks.
    • It fetches the registry (:176) and the health-checks builder (:177), then loops. Blank names and names an earlier call already claimed are skipped (:181-184), which is what makes the method idempotent.
    • For each surviving name it registers a named client whose BaseAddress is http://{name} and whose Timeout is ProbeTimeout (:189-201). Two comments there matter: the client timeout is the real probe budget while the registration timeout below only bounds the health-check service's own wait (:191-193), and the HTTP version is deliberately not pinned on the client because it belongs to the request, since under Auto a single poll may send both versions (:197-200).
    • It then adds a HealthCheckRegistration (:203-212) whose factory resolves IHttpClientFactory and constructs a DownstreamServiceHealthCheck with the captured probe version (:205-209), with failureStatus: HealthStatus.Unhealthy (:210), tags: [HealthCheckTags.Ready] (:211) and timeout: ProbeTimeout (:212).
  • Why it's built this way: A params array, so a gateway lists its downstreams in one line that reads like the topology, and an options overload rather than a per-name map, so the uncommon case costs a second call (see GatewayDownstreamHealthCheckOptions). Idempotence is engineered rather than assumed, because a duplicate health-check name is a startup exception (see GatewayDownstreamRegistry). The class carries the scoped CA1708 suppression (:66-69) for the extension(T) analyzer false positive. And the whole kit lives in MMCA.Common.Aspire rather than MMCA.Common.API because of a packaging constraint ADR-088 names explicitly: a YARP host has no controllers, so it never takes the API package, and anything the edge owns has to be reachable from the Aspire package alone.
  • Where it's used: MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:78 with four names (identity, conference, engagement, notification) and MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:112 with three (catalog, identity, sales). Both are single calls, and both Program.cs files carry a long comment explaining why no per-service pin is warranted now that the probe negotiates (ADC :66-77, Store :89-111, the latter naming the exact Kestrel log line and the e2e run where an h2c-pinned probe reported a Sales outage that did not exist). Covered by GatewayDownstreamHealthChecksTests (one check per service, the client registration, no pinned client version, idempotence, blank names) and by GatewayHardeningTests against the booted ADC host.
  • Caveats / not-in-source: The service names are strings with no compile-time relationship to the ReverseProxy cluster destinations in appsettings.json; a typo registers a check that can never pass rather than failing the build. The 2 second budget is a constant with no configuration knob.

GatewayCorrelationMiddleware

MMCA.Common.Aspire · MMCA.Common.Aspire.Gateway · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Gateway/GatewayCorrelationMiddleware.cs:27 · Level 9 · class (sealed)

  • What it is: Middleware that guarantees every request entering the edge carries an X-Correlation-ID, writing the value onto the request headers when the caller supplied none, and echoing it back on the response.
  • Depends on: RequestDelegate, HttpContext and System.Diagnostics.Activity only. Registered through GatewayCorrelationExtensions. It is the edge twin of CorrelationIdMiddleware in MMCA.Common.API.
  • Concept introduced, ensuring an id rather than reading one. [Rubric §13, Observability & Operability], [Rubric §13, Observability & Operability]. A correlation id minted independently by each service is not a correlation id: a browser call crossing the gateway into two services produces two unrelated ids, neither of them present in the gateway's own logs, and the one process guaranteed to see the request exactly once is the only one not stamping it. The fix is not "log an id at the edge" but "write the id onto the outbound request", because the service-side middleware adopts a header that is already present instead of minting its own (doc comment :18-24). ADR-088 records this as the first of the three responsibilities the edge owns, extending ADR-041's correlation id one hop outward.
  • Walkthrough:
    • HeaderName = "X-Correlation-ID" (:34), a public const. Its doc comment (:29-33) flags that this is deliberately the same literal as CorrelationIdMiddleware.HeaderName in MMCA.Common.API rather than a shared reference: the two packages share no dependency, and the whole point of the pair is that the edge and the services agree on the string. (Compare Extensions, which mirrors two configuration section names for the same reason and pins the duplication with a test.)
    • InvokeAsync(HttpContext) (:42) null-guards the context (:44) and reads the first inbound value of the header (:46).
    • When it is absent or whitespace (:48-55), the id is minted from Activity.Current?.TraceId with context.TraceIdentifier as the fallback (:53, the same precedence the service middleware uses, so the correlation id and the distributed trace line up), and then written back onto context.Request.Headers (:54). That single assignment is what makes the forwarded request carry it downstream.
    • The response echo is registered through context.Response.OnStarting (:57-62) rather than set inline, because a proxied response has its headers written by the forwarder; a callback fired as the response starts survives that. Finally the pipeline continues (:64).
  • Why it's built this way: sealed, conventional (constructor plus InvokeAsync) middleware, and context-free by constraint, not by accident. Its only constructor dependency is the RequestDelegate (:27). The service-tier version writes the id onto a scoped ICorrelationContext that the CQRS logging decorators read, and this Aspire package cannot reference the Application layer that declares that abstraction. The doc comment states the constraint directly (:10-17): a reverse-proxy gateway forwards bytes, it has no application container and no correlation context to populate, which is what makes this middleware safe to drop into a bare YARP host with no DI graph beyond the proxy.
  • Where it's used: Both gateway pipelines, deliberately first among each gateway's own concerns so the id is stamped before anything (a 429 from the edge limiter included) can short-circuit: MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:129 and MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:155. Covered by GatewayCorrelationMiddlewareTests, and end to end by GatewayHardeningTests in both apps.
  • Caveats / not-in-source: The middleware accepts a caller-supplied id verbatim; nothing validates its length or shape, so a client controls the value that appears in gateway and service logs.

GatewayCorrelationExtensions

MMCA.Common.Aspire · MMCA.Common.Aspire.Gateway · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Gateway/GatewayCorrelationMiddleware.cs:73 · Level 10 · class (static)

  • What it is: The one-line pipeline registration for GatewayCorrelationMiddleware: app.UseGatewayCorrelation().
  • Depends on: GatewayCorrelationMiddleware and IApplicationBuilder (ASP.NET Core).
  • Concept reinforced: the Use* pipeline-extension idiom. [Rubric §13, Observability & Operability], [Rubric §9, API & Contract Design]: the value this adds over calling UseMiddleware<T>() directly is the documented ordering rule, which is the part a host gets wrong.
  • Walkthrough: One extension(IApplicationBuilder app) block (:75) containing UseGatewayCorrelation() (:82), which null-guards the receiver (:84) and returns app.UseMiddleware<GatewayCorrelationMiddleware>() (:85). The doc comment carries the contract (:77-79): call it first, before the proxy or forwarder is mapped, so the id is on the request the gateway forwards. The class carries the same scoped CA1708 suppression as its siblings (:69-72) for the extension(T) analyzer false positive.
  • Why it's built this way: A static class with an extension(T) block is the codebase's standard registration idiom (see primer, C# extension(T) types, and ADR-106 for the policy). Keeping the extension in the same file as the middleware means the ordering rule and the behaviour it protects are read together.
  • Where it's used: MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:129 and MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:155, in both cases before every other gateway concern, and in particular before GatewayRateLimitingExtensions' UseGatewayRateLimiting() (ADC :144, Store :170), so a throttled request still carries a correlation id.

CspNonce

MMCA.Common.Aspire · MMCA.Common.Aspire.Security · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Security/SecurityHeaders.cs:110 · Level 0 · class (static)

  • What it is: The tiny public surface of the per-request Content-Security-Policy nonce: the key the nonce is stored under, and one Get(HttpContext) accessor a page render calls to read it.
  • Depends on: Nothing first-party. HttpContext (ASP.NET Core). Produced by SecurityHeadersMiddleware; the value it exposes originates from a CspPolicy that carries the {nonce} token.
  • Concept introduced, the CSP nonce as the supported escape from 'unsafe-inline'. [Rubric §11, Security] assesses HTTP response headers and the strength of the content policy behind them; [Rubric §26, Front-End Security] assesses how the browser-side app defends itself against injected script. A CSP that allows 'unsafe-inline' for scripts allows any injected <script> in the page to execute, which is precisely the attack CSP exists to stop. The standard alternative is a nonce: the response header names one unguessable per-response token, and only the tags carrying that exact token run. The mechanics are documented on the type itself (SecurityHeaders.cs:98-109): a policy asks for a nonce by writing the literal token {nonce} as a source-list entry (for example script-src 'self' {nonce}), the middleware generates a fresh value for that request, replaces every occurrence with the quoted source-list form 'nonce-<value>', and stashes the raw value in HttpContext.Items before the rest of the pipeline runs, because the page render is what needs to read it. A policy with no placeholder generates no nonce and stores nothing. ADR-023 records the placeholder as "the supported path off 'unsafe-inline'".
  • Walkthrough:
    • public const string ItemKey = "MMCA.CspNonce" (SecurityHeaders.cs:113), the HttpContext.Items key under which the raw (unquoted, Base64) value lives. It is public so a caller can read the item directly, but the accessor below is the intended door.
    • static string? Get(HttpContext context) (:120): null-guards the context (:122), then return context.Items.TryGetValue(ItemKey, out var value) ? value as string : null (:123). The as string rather than a cast means a foreign object stored under the same key degrades to null instead of throwing on a response path.
    • A null return has two indistinguishable meanings, and the doc comment says both (:115-118): the resolved policy carried no {nonce} placeholder, or the middleware did not run at all.
    • The intended consumption shape is in the remarks (:104-107): a layout injects IHttpContextAccessor and writes <script nonce="@CspNonce.Get(Http.HttpContext!)" src="app.js">.
  • Why it's built this way: A static class with a constant and one pure accessor, because there is nothing to inject and nothing to configure: the value already lives on the request. Putting the nonce in HttpContext.Items rather than in a scoped service keeps the producer (middleware) and the consumer (a Razor render further down the pipeline) decoupled with no DI registration on either side, which is what makes the feature free for a host that never uses it. Storing the raw value while the header carries the quoted 'nonce-<value>' form is deliberate: the two spellings differ, and a layout that stamped the quoted form onto an attribute would produce a token the browser never matches.
  • Where it's used: Written by SecurityHeadersMiddleware at SecurityHeaders.cs:187, immediately before the placeholder substitution at :188. Exercised end to end by SecurityHeadersMiddlewareTests, which asserts that the stored value is the one Get returns (MMCA.Common/Tests/Hosting/MMCA.Common.Aspire.Tests/Security/SecurityHeadersMiddlewareTests.cs:142-144), that two requests get different values (:156), that a policy without the placeholder stores nothing (:178-179), and that Get on a context the middleware never touched returns null (:183-184).
  • Caveats / not-in-source: No host in either app emits a {nonce} policy today. The shared BlazorCspPolicyProvider that both Blazor hosts register builds style-src 'self' 'unsafe-inline' (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Security/BlazorCspPolicyProvider.cs:81) rather than a nonce source list, and no .razor file in the workspace references CspNonce. The mechanism is framework capability with test coverage, not a live production path.

CspPolicy

MMCA.Common.Aspire · MMCA.Common.Aspire.Security · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Security/SecurityHeaders.cs:64 · Level 0 · record (sealed)

  • What it is: A resolved Content-Security-Policy: the directive string plus whether it is enforced or emitted report-only.
  • Depends on: Nothing first-party. Returned by ICspPolicyProvider, constructed by StaticCspPolicyProvider, consumed by SecurityHeadersMiddleware.
  • Concept introduced: [Rubric §11, Security] assesses HTTP security headers (CSP, HSTS, X-Frame-Options) as defence in depth; this record is the unit a policy provider hands the middleware. CSP itself is taught at the middleware section; here the point is the enforce versus report split. The whole security-headers pipeline is governed by ADR-023 (centralized security-response-headers middleware with a pluggable CSP).
  • Walkthrough: The whole type is the positional record CspPolicy(string Value, bool Enforce) (SecurityHeaders.cs:64, per-parameter doc comments at :61-63). Value is the full directive string, for example the framework baseline default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'. Enforce decides the header name: true makes the middleware write Content-Security-Policy (:193), false makes it write Content-Security-Policy-Report-Only (:197), which browsers report violations against without blocking anything, the standard way to trial a tightened policy without breaking the page.
  • Why it's built this way: sealed record for immutability and structural equality. Carrying Enforce on the record rather than as a second provider method keeps the policy and its enforcement mode atomic: a provider cannot return a policy string without saying how to enforce it. ADR-023 makes this two-field shape the contract.
  • Where it's used: Returned by ICspPolicyProvider.GetPolicy (SecurityHeaders.cs:75); constructed by StaticCspPolicyProvider (:89) and by the shared BlazorCspPolicyProvider; read by SecurityHeadersMiddleware.InvokeAsync (:177-199).
  • Caveats / not-in-source: A null return from a provider is a third state the record cannot express (emit no CSP at all); that case is handled by the nullable return type on ICspPolicyProvider, not here.

IWarmupTask

MMCA.Common.Aspire · MMCA.Common.Aspire.Warmup · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Warmup/IWarmupTask.cs:9 · Level 0 · interface

  • What it is: A unit of startup work executed once after the host starts, to take lazy initialisation off the first user request: cache priming, opening connection pools, pre-fetching discovery documents.
  • Depends on: Nothing. Implemented by the built-in OpenIdConnectMetadataWarmupTask and by every host task derived from SelfHttpWarmupTaskBase; run by WarmupHostedService; completion published through WarmupReadinessGate.
  • Concept introduced, startup warm-up. [Rubric §29, Resilience & Business Continuity] assesses proactive elimination of cold-start failure modes; [Rubric §12, Performance & Scalability] assesses whether latency is predictable rather than paid by whoever arrives first. After a deployment, or after an idle Azure Container Apps replica is scaled back up, the first requests hit cold paths: EF model build, connection-pool establishment, JIT, OIDC discovery. Warm-up tasks run those eagerly. Per the interface doc comment (IWarmupTask.cs:3-8), tasks run in parallel after host start, and failures are logged but do not prevent the readiness gate from opening, so a transient dependency outage cannot wedge a replica permanently out of rotation.
  • Walkthrough: string Name { get; } (IWarmupTask.cs:12), a stable identifier used in the runner's structured logs and, per the doc comment, in metrics. Task ExecuteAsync(CancellationToken cancellationToken) (:15) performs the work; the token is the host's stoppingToken, passed through by the runner.
  • Why it's built this way: An interface rather than an abstract class, for maximum implementation freedom. The one place the framework does supply a base class is the self-HTTP shape (SelfHttpWarmupTaskBase), where the mechanics rather than the contract are the shared risk. The non-fatal-failure contract is enforced by the runner (WarmupHostedService), not by the interface, so implementations stay simple and never reason about their own failure semantics. ADR-025 records the decision.
  • Where it's used: Discovered via DI. AddWarmupReadiness registers the built-in OpenIdConnectMetadataWarmupTask (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:113), and a host adds its own with AddWarmupTask<TTask>() (Extensions.cs:390, whose body is the single services.AddSingleton<IWarmupTask, TTask>() at :351). The whole IEnumerable<IWarmupTask> is injected into WarmupHostedService (Warmup/WarmupHostedService.cs:29).
  • Caveats / not-in-source: The interface carries no timeout, no ordering, and no retry. All three are the runner's business: tasks run concurrently in whatever order Task.WhenAll schedules them (WarmupHostedService.cs:53-54), each under a 120-second ceiling (:42, applied at :69), and a failed task is never retried by the subsystem, only re-paid lazily on the first real request.

KestrelListenerSpec

MMCA.Common.Aspire · MMCA.Common.Aspire.Kestrel · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Kestrel/KestrelEndpointExtensions.cs:132 · Level 0 · record (sealed, internal, nested)

  • What it is: One explicit Kestrel listener expressed as data: a port and the protocols accepted on it. It is the intermediate value that lets the listener decision be computed and tested without ever binding a socket.
  • Depends on: HttpProtocols (Microsoft.AspNetCore.Server.Kestrel.Core). Produced by KestrelEndpointExtensions.BuildListenerPlan and consumed by its ConfigureEndpointsWithHealthProbe.
  • Concept introduced, a pure plan object as a testability lever. [Rubric §14, Testability] assesses whether behavior can be verified without standing up infrastructure; [Rubric §15, Best Practices & Code Quality] covers the small-value-type habit that makes it possible. ConfigureKestrel is a callback that only runs when the host boots, and ListenAnyIP binds a real socket, so the logic of "which listeners does this configuration imply" would normally be unreachable from a test. Extracting that logic into a function returning IReadOnlyList<KestrelListenerSpec> turns the decision into a value you can assert on, which is exactly what KestrelEndpointExtensionsTests does across its cases.
  • Walkthrough: The entire type is internal sealed record KestrelListenerSpec(int Port, HttpProtocols Protocols) (KestrelEndpointExtensions.cs:132), a positional record nested inside KestrelEndpointExtensions, with per-parameter doc comments at :129-131. Port is the port to listen on; Protocols is the protocol set that port accepts (Http1, Http2, or Http1AndHttp2).
  • Why it's built this way: A record, so structural equality makes a test assertion read as a plain comparison of expected listeners. internal and nested, because it is an implementation detail of one extension class and no part of any public contract; the assembly's <InternalsVisibleTo Include="MMCA.Common.Aspire.Tests" /> (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/MMCA.Common.Aspire.csproj:13) is what keeps it reachable from the tests without widening the public surface. Modelling a listener as a pair rather than calling ListenAnyIP inline is the whole reason the surprising parts of the wiring (re-declaring the cleartext endpoint, forcing the probe listener to Http1) are verifiable at all.
  • Where it's used: Returned in declaration order by BuildListenerPlan (KestrelEndpointExtensions.cs:113-127) and looped over inside ConfigureKestrel at :94-97, where each spec becomes one kestrel.ListenAnyIP(listener.Port, endpoint => endpoint.Protocols = listener.Protocols) call.

SecurityHeadersSettings

MMCA.Common.Aspire · MMCA.Common.Aspire.Security · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Security/SecurityHeaders.cs:19 · Level 0 · class (sealed)

  • What it is: Strongly-typed configuration for SecurityHeadersMiddleware, centralising the security-header values each client-facing host previously hand-rolled.
  • Depends on: Nothing first-party. Consumed through IOptions<SecurityHeadersSettings> by StaticCspPolicyProvider and SecurityHeadersMiddleware.
  • Concept introduced: [Rubric §11, Security] assesses hardened HTTP response headers, and [Rubric §34, Architecture Governance & Documentation] assesses whether such a decision is written down once instead of re-derived per host. Previously each host set X-Frame-Options, Referrer-Policy, Permissions-Policy, HSTS, and CSP independently, inviting drift; pulling them into one shared settings class with hardened defaults means a new host inherits the safe values automatically, with per-host overrides through the "SecurityHeaders" configuration section or a configure delegate.
  • Walkthrough:
    • SectionName = "SecurityHeaders" (SecurityHeaders.cs:22), the configuration section the registration binds.
    • The defaults are the interesting part: FrameOptions = "DENY" (:25, anti-clickjacking, no framing at all); ReferrerPolicy = "strict-origin-when-cross-origin" (:28, leaks no path cross-origin); PermissionsPolicy denying geolocation, microphone, camera, and payment (:31); EnableHsts = true (:34) with HstsValue = "max-age=31536000; includeSubDomains" (:37, one year including subdomains).
    • ContentSecurityPolicy (:53-55) defaults to a complete hardened baseline: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'. The doc comment (:39-52) explains the calibration: script-src and style-src ship at exactly the strength Blazor ('wasm-unsafe-eval') and MudBlazor ('unsafe-inline' styles) require, so an HTML host that never registers a provider still gets a functional policy instead of one silently missing both directives, while the JSON, WebSocket, and static responses of API and Gateway hosts are unaffected. Setting the property to null or empty emits no CSP at all, and the {nonce} placeholder (see CspNonce) is named there as the supported path off 'unsafe-inline'.
    • EnforceContentSecurityPolicy = true (:58); set it false and the middleware emits the same policy report-only.
  • Why it's built this way: Sealed and mutable (get; set;, not init-only) so the configure delegate and IOptions binding can adjust the defaults before the middleware starts. The complete-baseline default is codified in ADR-023, whose Status block records the 2026-09-01 revision that replaced the earlier deliberately-incomplete baseline (which omitted script-src and style-src to avoid breaking a Blazor host) with one that ships both directives at Blazor and MudBlazor strength. The trade-off moved from "safe but partial" to "functional and still hardened", and the property's own doc comment carries the reasoning rather than leaving it only in the ADR.
  • Where it's used: Bound and registered by SecurityHeadersExtensions.AddCommonSecurityHeaders (SecurityHeaders.cs:226-230); read by StaticCspPolicyProvider (:86-89) and SecurityHeadersMiddleware (:157-158, :167-174). Both apps' UI hosts override EnableHsts to false through the configure delegate (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:103, MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:140), while both Gateways take the defaults (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:84, MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:71).
  • Caveats / not-in-source: X-Content-Type-Options has no setting; the middleware hard-codes nosniff (:167), so it is the one header a host cannot weaken through configuration.

SerilogHostExtensions

MMCA.Common.Aspire · MMCA.Common.Aspire.Logging · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Logging/SerilogHostExtensions.cs:27 · Level 0 · class (static)

  • What it is: The Serilog bootstrap every MMCA service host used to repeat verbatim, packaged as one call: console sink always, a rolling daily file sink outside Production, the framework's minimum-level overrides, and registration as one logging provider beside the others.
  • Depends on: Nothing first-party. Serilog (LoggerConfiguration, Log.Logger, RollingInterval, LogEventLevel), Serilog.Extensions.Logging (builder.Logging.AddSerilog), and Microsoft.Extensions.Hosting / Microsoft.AspNetCore.Builder (IHostEnvironment, WebApplicationBuilder). Called before AddServiceDefaults, whose OpenTelemetry provider joins the same factory.
  • Concept introduced, additive logging providers versus provider replacement. [Rubric §13, Observability & Operability] assesses whether application logs actually reach the place operators query, and this class exists because of a specific way that fails. [Rubric §13, Observability & Operability] and [Rubric §15, Best Practices & Code Quality] cover the collapse of seven hand-copied bootstraps into one. The load-bearing fact is in the class doc comment (SerilogHostExtensions.cs:16-21): UseSerilog() replaces the whole ILoggerFactory and therefore silently bypasses every other provider, including the OpenTelemetry-to-Azure-Monitor one wired by AddServiceDefaults(). A host that calls it publishes no application log line to Application Insights at all, and nothing fails: the symptom is absence, in a place nobody watches for absence. builder.Logging.AddSerilog(...) adds Serilog alongside the others, which is why this helper does that and never the former. ADR-041 records the decision and its costs.
  • Walkthrough, in teaching order.
    • The one public entry point. AddCommonSerilog(logFilePath, configure = null) (:48, inside an extension(WebApplicationBuilder builder) block at :29) null-guards the builder (:52), assigns Log.Logger = CreateLoggerConfiguration(builder.Environment, logFilePath, configure).CreateLogger() (:54), registers it with builder.Logging.AddSerilog(Log.Logger, dispose: true) (:55), and returns the builder (:57). Two things happen at once and both matter: the global Log.Logger is published (which is what the bootstrap factory below then reads) and the same instance is added as one provider on the host's existing factory. dispose: true hands the provider ownership of the logger so buffered events flush on shutdown. The doc comment (:31-36) states the ordering rule: call it before AddServiceDefaults(), as the hosts do, so the OpenTelemetry provider registered there joins the same factory.
    • CreateBootstrapLoggerFactory() (:67-68, public static): LoggerFactory.Create(logging => logging.AddSerilog()), a factory writing to the current global Log.Logger. It exists for the startup-time diagnostics a host needs before the DI container is built, module discovery above all (the framework's own module host names it in its doc comment, MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/ModuleHostExtensions.cs:39). The doc comment (:61-66) is explicit that the caller disposes it once startup wiring is done and that it does not own Log.Logger, which is why disposing it does not silence the host.
    • ResolveMinimumLevel(environment) (:76-77, internal static): Debug in Development, Information everywhere else. One expression, extracted so the policy can be asserted rather than inferred.
    • ShouldWriteFileSink(environment) (:87-88, internal static): !environment.IsProduction(). The doc comment (:79-86) gives the reasoning, and it is a genuine operational judgement rather than a preference: a Production container writes to ephemeral disk nothing reads, and stdout plus OpenTelemetry already carry production logs, so the file sink is added everywhere except Production, which is local development and the CI E2E stack, where the file is what a failure gets diagnosed from.
    • CreateLoggerConfiguration(environment, logFilePath, configure) (:98, internal static) is where the defaults actually live. It guards both inputs, including ArgumentException.ThrowIfNullOrWhiteSpace(logFilePath) (:103-104), then builds MinimumLevel.Is(ResolveMinimumLevel(environment)) with two noise overrides, Microsoft.EntityFrameworkCore and Microsoft.AspNetCore both floored at Warning (:107-109), and WriteTo.Console(formatProvider: CultureInfo.InvariantCulture) (:110). When ShouldWriteFileSink says so it adds WriteTo.File(logFilePath, rollingInterval: RollingInterval.Day, ...) (:112-118). Only then does it invoke the optional configure hook (:120) and return the uncreated configuration (:122).
    • The ordering in that last method is the testability lever: the hook runs after the defaults, so a host can add a sink or override a level, and the method stops short of CreateLogger(), so the shape can be asserted without publishing a global logger. [Rubric §14, Testability] shows up exactly there, and SerilogHostExtensionsTests drives all three internal helpers plus the public call.
  • Why it's built this way: A static class with one extension(WebApplicationBuilder) block, the codebase's standard registration idiom (see primer, C# extension(T) types), which is also why it carries the scoped CA1708 suppression with the false-positive justification spelled out (:23-26). The invariant that matters (exactly one Serilog provider, never a factory replacement) is guarded by a framework test that asserts the built container contains exactly one SerilogLoggerProvider (MMCA.Common/Tests/Hosting/MMCA.Common.Aspire.Tests/Logging/SerilogHostExtensionsTests.cs:139), which ADR-041 flags as guarded in the framework rather than at the consumer: nothing in a host's own build stops a new service from reaching for UseSerilog().
  • Where it's used: All seven ADC and Store service hosts, each pairing AddCommonSerilog with CreateBootstrapLoggerFactory() in the same file: ADC Conference (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:100 and :307), Engagement (:82, :208), Identity (:96, :247), Notification (:85, :186); Store Catalog (MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:67 and :121), Identity (:73, :117), Sales (:84, :130).
  • Caveats / not-in-source: Adoption is not uniform, and the gap is visible in source. Neither Gateway references Serilog in its Program.cs, and neither does MMCA.ADC.UI.Web: those three take the plain OpenTelemetry logging that AddServiceDefaults gives them. MMCA.Store.UI.Web is a third shape, hand-rolling the same configuration inline (MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:66) and registering it as one provider (:74) without calling the helper, so the defaults exist in two places and a framework change reaches seven hosts and misses the eighth. ADR-041 records both facts as accepted costs. The helper also attaches only to WebApplicationBuilder, not to the generic IHostApplicationBuilder that AddServiceDefaults uses, so a non-web worker host cannot call it.

WarmupReadinessGate

MMCA.Common.Aspire · MMCA.Common.Aspire.Warmup · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Warmup/WarmupReadinessGate.cs:10 · Level 0 · class (sealed)

  • What it is: A thread-safe one-shot flag that WarmupHostedService flips to true exactly once when every IWarmupTask has finished, failed, or timed out, gating the /health/ready endpoint.
  • Depends on: Nothing first-party. Read by WarmupReadinessHealthCheck, set by WarmupHostedService. Uses Volatile and Interlocked (BCL).
  • Concept introduced, the readiness probe. [Rubric §29, Resilience & Business Continuity], [Rubric §17, DevOps], [Rubric §13, Observability & Operability]. Azure Container Apps (and Kubernetes) withhold traffic from a replica until its readiness probe returns healthy. The health check reports unhealthy until IsReady is true, so a fresh replica does not receive production traffic until warm-up has had its chance, avoiding slow or failed first requests (ADR-025). Note the deliberate split from liveness: readiness is what holds traffic back, while /alive stays narrow so a dependency outage never restarts the container (see HealthCheckTags).
  • Walkthrough: private int _isReady (WarmupReadinessGate.cs:12), 0 or 1. Using an int with Volatile.Read and Interlocked.Exchange rather than a bool plus a lock gives lock-free, correctly published reads and writes. IsReady => Volatile.Read(ref _isReady) == 1 (:15): the volatile read prevents a CPU from returning a stale cached value, which matters because the writer is a background thread and the readers are probe requests on the thread pool. internal void MarkReady() => Interlocked.Exchange(ref _isReady, 1) (:18): idempotent, safe to call twice, and the doc comment says so.
  • Why it's built this way: Sealed, so no subclass can reinterpret readiness. MarkReady() is internal (:18), so no external caller can prematurely open the gate; only WarmupHostedService, in the same assembly, may. The type carries no "not ready again" transition on purpose: readiness here means "warm-up has run", a startup fact rather than a live health signal, and the live signals are the separate dependency checks that also sit on /health/ready.
  • Where it's used: Registered as a singleton by AddWarmupReadiness (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:110); opened in the finally of WarmupHostedService.ExecuteAsync (Warmup/WarmupHostedService.cs:58); read by WarmupReadinessHealthCheck.CheckHealthAsync (Warmup/WarmupReadinessHealthCheck.cs:14), which is registered as the "warmup" check tagged HealthCheckTags.Ready (Extensions.cs:115-116) and surfaced at /health/ready (Extensions.cs:433-435). Covered by WarmupReadinessGateTests.

ICspPolicyProvider

MMCA.Common.Aspire · MMCA.Common.Aspire.Security · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Security/SecurityHeaders.cs:72 · Level 1 · interface

  • What it is: The contract for resolving the Content-Security-Policy for a response. The framework ships a static default; a host that needs a dynamic policy registers its own implementation.
  • Depends on: CspPolicy (return type); HttpContext (ASP.NET Core).
  • Concept introduced, the CSP extension point. [Rubric §11, Security] (CSP as defence in depth) and [Rubric §26, Front-End Security] (CSP protecting Blazor pages from injected script). The doc comment (SecurityHeaders.cs:66-71) states the extensibility rule: a Blazor host needing a dynamic connect-src, for example one pinned at runtime to its API origin, registers a custom ICspPolicyProvider before calling AddCommonSecurityHeaders, because that method registers the default only via TryAddSingleton (:237), so a pre-registered custom provider wins.
  • Walkthrough: A single method, CspPolicy? GetPolicy(HttpContext context) (SecurityHeaders.cs:75): returns the policy to emit for the current response, or null to emit none. Taking the HttpContext makes it per-request, so a provider can vary the policy by path or request properties. [Rubric §1, SOLID] is the shape: one method, one reason to change, and the middleware depends on this abstraction rather than on any concrete policy source.
  • Why it's built this way: A one-method interface is the minimal extension point for a per-consumer CSP allow-list; returning the CspPolicy record rather than a bare string carries the enforce-or-report decision atomically. ADR-023 explains why the indirection exists at all: a JSON/Gateway host and a Blazor/MudBlazor host need different policies (the latter must pin connect-src to an origin it only learns at runtime from configuration), and a wrong CSP hard-breaks the app, so the policy must be resolved through a provider rather than baked in as a constant.
  • Where it's used: Implemented by the default StaticCspPolicyProvider (SecurityHeaders.cs:79) and by the shared BlazorCspPolicyProvider in MMCA.Common.UI.Web, which both apps' Blazor hosts register through AddCommonBlazorCsp (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:39) ahead of AddCommonSecurityHeaders (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:102-103, MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:139-140). Resolved per request inside SecurityHeadersMiddleware (SecurityHeaders.cs:177).

KestrelEndpointExtensions

MMCA.Common.Aspire · MMCA.Common.Aspire.Kestrel · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Kestrel/KestrelEndpointExtensions.cs:24 · Level 1 · class (static)

  • What it is: One call, ConfigureEndpointsWithHealthProbe(...), that applies a protocol profile to every Kestrel endpoint default and, when a probe port is configured, adds a dedicated HTTP/1.1-only listener so the platform's httpGet health probes have something they can actually speak to.
  • Depends on: KestrelListenerSpec (the plan it computes); WebApplicationBuilder, HttpProtocols, IConfiguration (ASP.NET Core and BCL). Pairs with the health endpoints mapped by the Common.Aspire Extensions MapDefaultEndpoints().
  • Concept introduced, the protocol mismatch between gRPC hosts and platform probes. [Rubric §7, Microservices Readiness], [Rubric §13, Observability & Operability], [Rubric §17, DevOps], [Rubric §29, Resilience & Business Continuity]. The class doc comment (KestrelEndpointExtensions.cs:8-23) states the two facts that force the design. First, a service serving inbound gRPC on cleartext must answer HTTP/2 with prior knowledge (h2c): there is no TLS, therefore no ALPN to negotiate with, and the typed gRPC clients from MMCA.Common.Grpc speak h2c directly. Second, Azure Container Apps httpGet probes speak HTTP/1.1, which an Http2-only endpoint rejects with GOAWAY HTTP_1_1_REQUIRED. The consequence is the operational point worth internalising: that mismatch is why those probes used to be TCP-only and never consulted the real, dependency-aware health checks, meaning the platform could not tell a live socket from a service whose database was gone. A separate Http1-only listener on a port never published through ingress gives the platform a probe target, and because MapDefaultEndpoints() maps /health, /alive, and /health/ready on every listener, that probe listener serves the real health pipeline. ADR-012 (gRPC host transport) is the governing decision.
  • Walkthrough:
    • Two public constants. HealthProbePortConfigKey = "HealthProbe:Port" (:31), which deployment infrastructure injects as HealthProbe__Port and which the doc comment (:26-30) says is deliberately absent locally so Aspire's dynamic ports keep working and co-hosted services cannot collide on one machine. DefaultCleartextPort = 8080 (:37), the platform's own default binding.
    • ConfigureEndpointsWithHealthProbe(defaultProtocols, redeclareCleartextEndpoint = true, cleartextPort = DefaultCleartextPort) (:77, inside an extension(WebApplicationBuilder builder) block at :39): null-guards the builder (:82), computes the listener plan up front (:84-88), then inside builder.WebHost.ConfigureKestrel (:90) sets kestrel.ConfigureEndpointDefaults(endpoint => endpoint.Protocols = defaultProtocols) (:92) and declares each planned listener with kestrel.ListenAnyIP(...) (:94-97).
    • The two deployed profiles are the same call with different arguments, and the doc comment (:48-60) spells both out. A REST/gRPC service passes HttpProtocols.Http2 and keeps redeclareCleartextEndpoint at its default, because an explicit Listen call overrides the container's ASPNETCORE_HTTP_PORTS default binding, so the main h2c endpoint has to be re-declared next to the probe port or it silently disappears. A host whose endpoints come from configuration (for example a SignalR host running an Http1AndHttp2 endpoint for the WebSocket upgrade handshake plus an Http2-only gRPC endpoint, both from appsettings.json) passes HttpProtocols.Http1AndHttp2 and redeclareCleartextEndpoint: false, so the probe listener is strictly additive and nothing re-binds a port the configuration already owns.
    • BuildListenerPlan(configuration, defaultProtocols, redeclareCleartextEndpoint, cleartextPort) (:113, internal static) is the pure decision function and the only place the branching lives. configuration.GetValue<int?>(HealthProbePortConfigKey) is not int probePort returns an empty plan (:119-122), which is the local and test case: endpoint defaults alone, Aspire's dynamic ports intact. Otherwise it returns either two specs ([new KestrelListenerSpec(cleartextPort, defaultProtocols), new KestrelListenerSpec(probePort, HttpProtocols.Http1)]) or just the probe spec, depending on redeclareCleartextEndpoint (:124-126). The probe listener is always HttpProtocols.Http1, whatever the defaults are, which is the entire point.
    • Failure behavior is deliberate and documented on the method (:72-76): a probe port that is not an integer makes GetValue<int?> throw InvalidOperationException at startup, because a mistyped probe port that silently produced no listener would leave the platform probing a closed port and the revision would never come up. A blank value, by contrast, is treated as absent.
  • Why it's built this way: The decision is separated from the binding so it can be asserted as data (see KestrelListenerSpec). The probe port is configuration-gated rather than always-on so local and test runs keep Aspire's dynamic ports. Failing fast rather than failing quiet follows the same contract as ADR-070: a misconfiguration whose only symptom is a silently unprobeable revision is worth a startup crash. And the helper lives in the shared framework package rather than in each service, which is what let seven service hosts across two apps collapse onto one implementation.
  • Where it's used: All four ADC services, three on the h2c profile (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:81, MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:85, MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:68) and Notification on the mixed profile (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:71, passing HttpProtocols.Http1AndHttp2, redeclareCleartextEndpoint: false); plus all three Store services, two on h2c (MMCA.Store/Source/Services/MMCA.Store.Identity.Service/Program.cs:64, MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:58) and Sales on the mixed profile (MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:75). Covered by KestrelEndpointExtensionsTests, whose cases pin the empty-plan, blank-value, fail-fast, both-profile, always-Http1, and custom-cleartext-port behaviors.
  • Caveats / not-in-source: The probe port is supplied per app by the Container Apps Bicep rather than by anything in application code, so a host whose deployment omits HealthProbe__Port simply gets the empty plan and answers probes on its configured endpoints. Source does not state why the two mixed-profile hosts (ADC Notification and Store Sales) are the ones configured from appsettings.json.

OpenIdConnectMetadataWarmupTask

MMCA.Common.Aspire · MMCA.Common.Aspire.Warmup · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Warmup/OpenIdConnectMetadataWarmupTask.cs:21 · Level 1 · class (sealed, internal, partial)

  • What it is: The one built-in IWarmupTask. It pre-fetches the OIDC discovery document on startup, warming the network path so the first authenticated request does not hit a cold connection.
  • Depends on: IWarmupTask (implements); IHttpClientFactory, IConfiguration, and ILogger<T> (BCL and DI).
  • Concept introduced: [Rubric §29, Resilience & Business Continuity]. The doc comment (OpenIdConnectMetadataWarmupTask.cs:6-13) names the failure mode precisely: without warm-up, the JWT bearer middleware fetches {authority}/.well-known/openid-configuration lazily on the first authenticated request, and on a CPU-throttled idle ACA Consumption replica that fetch can stretch past the client timeout, the textbook "first request fails, second succeeds" pattern. Pre-fetching warms DNS, TCP, TLS, and the HttpClient pool. The honest caveat is stated in the type's own remarks (:14-20): the middleware's ConfigurationManager caches discovery state separately, so it still performs its own fetch on the first request; the intent is that the fetch now runs over a warm connection and completes in single-digit milliseconds.
  • Walkthrough: A primary constructor injects IHttpClientFactory, IConfiguration, and ILogger<OpenIdConnectMetadataWarmupTask> (:21-24). Name => "OpenIdConnectMetadata" (:26). ExecuteAsync (:28) reads Authentication:JwtBearer:Authority (:30) and returns immediately when it is unset (:31-34), so a non-authenticating host pays nothing. It then builds the discovery URI by trimming a trailing slash and appending /.well-known/openid-configuration (:36-39), logging a warning and returning if the result is not a valid absolute URI (:41-42). Finally it creates a client from the factory under the task type's own name (:45) and issues a GET (:46), logging the status code (:48). Both log lines are source-generated [LoggerMessage] partial methods (:51-57): EventId 1 at Warning for the invalid authority, EventId 2 at Information for the fetch.
  • Why it's built this way: internal sealed partial. Internal because it is wired by the framework's AddWarmupReadiness rather than by consumers, partial for the [LoggerMessage] source generator, sealed by default policy. Reusing IHttpClientFactory is the load-bearing detail: the warm-up exercises exactly the connection pool (and the tuned SocketsHttpHandler from HttpResilienceDefaults) that the real auth path will use, so the warmth transfers. It reads the same configuration key the AppHost's JWKS discovery wiring injects, which is what makes the task self-configuring rather than needing its own settings class. ADR-025 records both the decision and the middleware-cache caveat.
  • Where it's used: Registered as an IWarmupTask singleton by AddWarmupReadiness (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:113), so every host that calls AddServiceDefaults gets it; executed in parallel with any host-registered tasks by WarmupHostedService.
  • Caveats / not-in-source: The HTTP call passes no explicit timeout of its own. It is bounded twice from outside: by the shared Polly total-request timeout that ConfigureHttpClientDefaults applies to every factory client (Extensions.cs:69, 90 seconds from HttpResilienceDefaults, MMCA.Common/Source/Core/MMCA.Common.Shared/Resilience/HttpResilienceDefaults.cs:19), and by the runner's 120-second per-task ceiling (Warmup/WarmupHostedService.cs:42). A non-2xx discovery response is logged at Information like any other status (:48) rather than treated as a failure, so a 404 authority warms the connection and reports success.

SelfHttpWarmupTaskBase

MMCA.Common.Aspire · MMCA.Common.Aspire.Warmup · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Warmup/SelfHttpWarmupTaskBase.cs:28 · Level 1 · class (public, abstract, partial)

  • What it is: The shared base class for the second family of warm-up tasks: once the server is listening, replay a short list of this host's own hot read paths against its own Kestrel endpoint. A derived task supplies only a name and the paths; every mechanism (waiting for the server, resolving the port, pinning the HTTP version, deciding what counts as failure, keeping failure non-fatal) lives here (SelfHttpWarmupTaskBase.cs:11-22).
  • Depends on: IWarmupTask (implements). Its five primary-constructor parameters are all ASP.NET Core or hosting abstractions: IServer plus IServerAddressesFeature (the actual bound address), IConfiguration (the ASPNETCORE_URLS fallback), IHostEnvironment (the Testing short-circuit), IHostApplicationLifetime (the ApplicationStarted wait), and ILogger (:28-33). It creates its own SocketsHttpHandler and HttpClient (:111-117) rather than taking an IHttpClientFactory.
  • Concept introduced, self-request warm-up, and the template-method shape. The built-in OIDC task warms one outbound connection; this one warms the inbound path, which is where the cold-start cost of an idle, CPU-throttled replica actually lives: ingress connection, Kestrel, output cache, routing, authentication, the controller, EF Core, SQL (:12-15). [Rubric §12, Performance & Scalability] assesses whether latency is predictable, and the whole point here is to move first-request JIT and query-plan cost off the first user and onto startup. [Rubric §29, Resilience & Business Continuity] covers the failure posture: every fault path in this class ends in a log line, never in an exception that reaches the runner. [Rubric §2, Design Patterns] is the shape itself, a textbook template method: one concrete ExecuteAsync (:93) calling five members a subclass may or must supply (Name :49 and WarmupPaths :59 abstract; RequestVersion :70, RequestVersionPolicy :77, and RequireSuccessStatusCode :90 virtual with defaults). [Rubric §15, Best Practices & Code Quality] is why it exists at all: six services across the two apps ran hand-copied versions of this logic, and the framework tests describe those copies as "the behaviors the per-service copies each had to get right on their own" (MMCA.Common/Tests/Hosting/MMCA.Common.Aspire.Tests/Warmup/SelfHttpWarmupTaskBaseTests.cs:21-26).
  • Walkthrough, in teaching order.
    • Two constants. public const int DefaultPort = 8080 (:39), the containerized default every deployed service listens on, used only when nothing else resolves; and private const string TestingEnvironmentName = "Testing" (:46), whose doc comment explains the short-circuit below: an integration test boots the host through WebApplicationFactory, whose in-memory TestServer never opens a socket, so a self-HTTP request there could only ever fail (:41-45).
    • The extension points. Name (:49) and WarmupPaths (:59) are abstract. The WarmupPaths doc comment carries the single most important rule for a derived task (:53-57): the paths must match what real callers issue character for character in their values, not just in their shape, because an output-cache policy that varies by query string keys the entry on the exact URL, so a warmed entry built from different values is an entry nothing ever reads. RequestVersion defaults to HttpVersion.Version20 (:70) and RequestVersionPolicy to HttpVersionPolicy.RequestVersionExact (:77), which together are a pin, not a preference: the h2c prior-knowledge hosts serve HTTP/2 only on cleartext, and a silent downgrade to HTTP/1.1 would be rejected with a 400 "An HTTP/1.x request was sent to an HTTP/2 only endpoint", failing the warm-up on every single startup (:62-69). A host with no inbound gRPC server stays Http1AndHttp2 and overrides both members. RequireSuccessStatusCode defaults to true (:90), suiting an anonymous read whose response body is what populates the output cache; a protected endpoint overrides it to false, because an unauthenticated self-request against an [Authorize] route gets 401 by design and that refusal still traverses Kestrel, routing, authentication, and the middleware pipeline, which is the JIT cost the warm-up exists to pay down (:80-89).
    • ExecuteAsync(CancellationToken) (:93). First the Testing short-circuit, which returns before anything else and therefore before the server wait (:95-98). Then WaitForServerStartedAsync (:102), defined at :191-199: it registers a callback on lifetime.ApplicationStarted that completes a TaskCompletionSource (created with TaskCreationOptions.RunContinuationsAsynchronously, :193-196) and awaits it with .WaitAsync(cancellationToken) (:198). The comment above it states the reason (:189-190): the warm-up runner is a hosted service that starts before Kestrel begins listening, since the web host is the last hosted service, so self-requesting immediately would race the listener.
    • Building the client. The base address is a UriBuilder over http, localhost, and the port from ResolveWarmupPort (:104-109); the SocketsHttpHandler and HttpClient are created locally with disposeHandler: false and both disposed by using (:111-117), and the client carries DefaultRequestVersion and DefaultVersionPolicy from the two virtuals (:115-116).
    • The replay loop (:119-133): each path is issued as a relative GET (:121-123). When RequireSuccessStatusCode is on, EnsureSuccessStatusCode() throws on a non-2xx (:127) and the body is then read to completion (:131), which the comment explains is the point of an output-cache warm-up: the entry is only worth priming if the whole response was produced and transferred (:129-130). When it is off, neither the status check nor the body drain happens, so the 401-profile task pays only the pipeline traversal. A success logs once for the whole task, with the name, the path count, and the base address (:135).
    • The failure boundary (:137-146). An OperationCanceledException is rethrown when the token is actually cancelled (:137-140), so host shutdown is not mistaken for a warm-up failure; every other exception is caught under an explicit #pragma warning disable CA1031 whose comment states the policy, warm-up failures are non-fatal by design, log and fall back to lazy warm-up (:141-143), and logged at Warning (:145). Note where the try starts (:100): the server wait, the port resolution, and the loop are all inside it, so a bad path list cannot take the host down either.
    • ResolveWarmupPort(IServer, IConfiguration) (:157-166), internal static so it can be tested directly. It prefers the first http:// address from the server's IServerAddressesFeature (:159-160), which is the only correct answer under Aspire's dynamic ports, falls back to SelectCleartextUrl(configuration["ASPNETCORE_URLS"]) (:161), then parses the port by trimming a trailing slash and taking the last colon-delimited segment under CultureInfo.InvariantCulture (:163), and finally falls back to DefaultPort when nothing parses (:165).
    • SelectCleartextUrl(string?) (:176-187) picks the cleartext entry out of a possibly semicolon-separated ASPNETCORE_URLS list (:183), returning the first http:// entry or, if there is none, the first entry at all (:185-186). Its doc comment explains why this stays string handling rather than Uri parsing (:168-173): wildcard hosts such as + and * are legal in that variable and Uri rejects them.
    • Two [LoggerMessage] partial methods (:201-207): EventId 1 at Information for the completed replay, EventId 2 at Warning carrying the exception for the failure, whose message is written for an operator reading a startup log, "first requests may be slow".
  • Why it's built this way:
    • An abstract class, where the sibling contract is an interface. IWarmupTask stays a bare interface because warm-up work has no shared mechanism; self-HTTP warm-up is the opposite case, where the mechanism is the entire risk and the per-service part is a string array. Making the shared part inheritable, and the varying part abstract or virtual with a documented default, is what turns six copies into six path lists.
    • public, unlike its warm-up siblings. WarmupHostedService, WarmupReadinessHealthCheck, and OpenIdConnectMetadataWarmupTask are internal because the framework wires them; this type is public precisely because consumers derive from it in their own assemblies.
    • Its own handler rather than IHttpClientFactory. The self-request must be pinned to a specific HTTP version against this host's own listener; a factory client would inherit the global ConfigureHttpClientDefaults pipeline, whose resilience handler and shared primary handler are tuned for outbound service-to-service calls, not for one loopback request at startup.
    • internal static port resolution. Extracting the resolution into a pure static function is what makes the trickiest logic in the file testable without a running host, and [Rubric §14, Testability] shows up exactly there: five test methods exercise ResolveWarmupPort alone (MMCA.Common/Tests/Hosting/MMCA.Common.Aspire.Tests/Warmup/SelfHttpWarmupTaskBaseTests.cs:68-113), covering the bound-address preference, the HTTPS skip, both ASPNETCORE_URLS fallbacks, and the container default.
    • The subsystem as a whole is ADR-025, and the HTTP/2-only cleartext endpoints this class must speak to are ADR-012.
  • Where it's used: Never registered by the framework. A host derives a task and registers it with AddWarmupTask<TTask>() (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:390). ADC has three derived tasks: SelfHttpOutputCacheWarmupTask in Conference, which replays a fixed list of anonymous read URLs (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/SelfHttpOutputCacheWarmupTask.cs:59, :62, registered at .../MMCA.ADC.Conference.Service/Program.cs:256), plus a SelfHttpWarmupTask in Engagement (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/SelfHttpWarmupTask.cs:39, :42, registered at Program.cs:157) and a SelfHttpWarmupTask in Identity (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/SelfHttpWarmupTask.cs:39, :42, registered at Program.cs:169), both of which override RequireSuccessStatusCode to false because their one path is protected (:49 in each). Store has three more of the same shape (Identity Program.cs:144, Catalog Program.cs:162, Sales Program.cs:157); its Catalog task takes the defaults outright (MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/SelfHttpOutputCacheWarmupTask.cs:46, :49), while its Sales task is the one that overrides the version pin as well, staying on HTTP/1.1 with RequestVersionOrLower because that host serves no inbound gRPC (MMCA.Store/Source/Services/MMCA.Store.Sales.Service/SelfHttpOutputCacheWarmupTask.cs:56, :59). Behavior is covered by SelfHttpWarmupTaskBaseTests, which drives a configurable subclass against real Kestrel listeners started as Http2 or Http1.
  • Caveats / not-in-source:
    • A required-success failure abandons the remaining paths. EnsureSuccessStatusCode throws out of the loop into the outer catch (:127, :142), so with the default profile the first bad path ends the run and the later ones are never warmed.
    • The value-exactness rule is a convention, not a check. Nothing verifies that a derived task's WarmupPaths match the URLs real callers build, so a drifted query string warms a cache entry no caller reads, silently and with a success log. The derived tasks record the mapping in comments.
    • Not determinable from source: how much first-request latency this actually removes on a CPU-throttled ACA replica. The class documents the cost model it is built against (:12-15), and the tests prove the requests are issued and the failures are non-fatal, but no benchmark or gate in the repo measures the saving.

WarmupHostedService

MMCA.Common.Aspire · MMCA.Common.Aspire.Warmup · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Warmup/WarmupHostedService.cs:28 · Level 1 · class (sealed, internal, partial)

  • What it is: The BackgroundService that runs every registered IWarmupTask exactly once on startup, in parallel and each under a per-task timeout, then opens the WarmupReadinessGate.
  • Depends on: IWarmupTask (the set it runs, injected as IEnumerable<IWarmupTask>) and WarmupReadinessGate (the gate it opens); BackgroundService, ILogger<T>, Stopwatch, plus the two optional primary-constructor parameters TimeProvider? timeProvider and TimeSpan? taskTimeout (WarmupHostedService.cs:32-33) that back the per-task timeout (BCL).
  • Concept introduced, fail-open startup gating. [Rubric §29, Resilience & Business Continuity] (the startup readiness gate) and [Rubric §13, Observability & Operability] (timing logs per task and overall). The defining design choice, stated in the class doc comment (WarmupHostedService.cs:7-19), is that the gate opens even if tasks fail: a stuck dependency must not keep the replica out of traffic rotation forever (:9-12). The second paragraph of that comment (:13-19) extends fail-open to the harder case: failure was already harmless, but a task that neither completes nor throws used to leave Task.WhenAll pending forever and the gate closed with it, "strictly worse than serving a cold one", so the per-task ceiling turns hanging into the same log-and-continue path. Both are availability chosen over strict warmth, and ADR-025 calls it the load-bearing decision of the subsystem.
  • Walkthrough:
    • Constructor and timeout state first: the primary constructor (:28-33) takes the task set, the gate, and the logger, then two optional parameters, TimeProvider? timeProvider = null and TimeSpan? taskTimeout = null. They exist for testability, and the doc comment says so (:24-27): the override lets tests exercise the timeout path without waiting two minutes, while production always takes the default. The backing fields resolve the defaults once, _timeProvider = timeProvider ?? TimeProvider.System (:44) and _taskTimeout = taskTimeout ?? TimeSpan.FromSeconds(TaskTimeoutSeconds) (:45), over the private const int TaskTimeoutSeconds = 120 (:42) whose doc comment (:35-41) insists it is a backstop, not a latency budget.
    • ExecuteAsync(CancellationToken stoppingToken) (:47): starts an overall Stopwatch (:49), runs Task.WhenAll(tasks.Select(task => RunOneAsync(task, stoppingToken))) (:53-54), and, crucially, opens the gate in a finally block, gate.MarkReady() (:58) followed by LogWarmupComplete with the elapsed milliseconds (:59). A failing or hanging task therefore can never keep the replica permanently unready.
    • RunOneAsync(task, cancellationToken) (:63): times each task with its own Stopwatch (:65), then awaits task.ExecuteAsync(cancellationToken) (:68) chained through .WaitAsync(_taskTimeout, _timeProvider, cancellationToken) (:69), and logs the completion with the task name and duration (:71). Three catch clauses follow, in order. It rethrows OperationCanceledException when the host is actually stopping (:73-76, the when (cancellationToken.IsCancellationRequested) filter), so shutdown is not mistaken for a task failure. A TimeoutException from the WaitAsync ceiling is caught and logged at Warning through LogTaskTimedOut with the task name, elapsed milliseconds, and _taskTimeout.TotalSeconds (:77-82); the comment there (:79-80) states the outcome plainly: same outcome as a failure, the abandoned task keeps running detached, the gate opens, and the dependency is retried lazily on first use. Every other exception is caught and logged at Warning (:84, :87), with #pragma warning disable CA1031 and the comment "warm-up failures must never crash the host" (:83-85) documenting that swallowing a general exception is intentional here.
    • Four source-generated [LoggerMessage] methods (:91-105): LogTaskCompleted (:93, EventId 1, Information), LogTaskFailed (:97, EventId 2, Warning, carrying the exception), LogWarmupComplete (:101, EventId 3, Information), and LogTaskTimedOut (:105, EventId 4, Warning, carrying the exception plus the limit in seconds so the log line names the ceiling that fired).
  • Why it's built this way: internal sealed partial. Running the tasks with Task.WhenAll rather than sequentially means total warm-up time is the slowest task, not the sum. The finally-open-the-gate pattern is the resilience invariant from IWarmupTask's contract, expressed in the one place that can enforce it, and the per-task WaitAsync is what makes that invariant hold against a task that hangs instead of throwing. Distinguishing host cancellation (rethrow) from timeout and from ordinary failure (both log and continue) keeps shutdown clean while making warm-up best-effort. Two details are deliberate: the WaitAsync overload takes a TimeProvider, which is why the constructor accepts one, so a test can drive the timeout path without a real wall-clock wait; and 120 seconds is chosen to sit above the 90-second shared Polly total-request timeout that already bounds the built-in OIDC task's HTTP call (HttpResilienceDefaults, MMCA.Common/Source/Core/MMCA.Common.Shared/Resilience/HttpResilienceDefaults.cs:19, applied in Extensions.cs:69), so per the constant's own comment (:36-41) a task that reaches this limit is one that bypassed those defaults or is waiting on something that will never arrive.
  • Where it's used: Registered as a hosted service by AddWarmupReadiness (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:111), which supplies no values for the two optional parameters, so production runs on TimeProvider.System and the 120-second default. Covered by WarmupHostedServiceTests, which constructs the service directly and passes a short taskTimeout to exercise the hanging-task case.
  • Caveats / not-in-source: WaitAsync abandons rather than cancels, so a timed-out task keeps running detached for the life of the host (the comment at :79-80 says exactly this); the only token that can stop it is the host's stoppingToken at shutdown. The ceiling also bounds the damage rather than removing it: a replica whose warm-up hangs stays out of rotation for the full 120 seconds before it is admitted. And the ceiling is not operator-tunable, since TaskTimeoutSeconds is a private const (:42) and the taskTimeout parameter is reachable only from a direct constructor call, with no configuration binding anywhere in the file.

WarmupReadinessHealthCheck

MMCA.Common.Aspire · MMCA.Common.Aspire.Warmup · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Warmup/WarmupReadinessHealthCheck.cs:9 · Level 1 · class (sealed, internal)

  • What it is: An IHealthCheck that reports unhealthy until the WarmupReadinessGate opens. It is registered with the ready tag so it appears on the /health/ready endpoint that ACA readiness probes hit.
  • Depends on: WarmupReadinessGate (primary-constructor injection); IHealthCheck and HealthCheckResult (Microsoft.Extensions.Diagnostics.HealthChecks).
  • Concept introduced: [Rubric §13, Observability & Operability], [Rubric §29, Resilience & Business Continuity]. This is the bridge between the in-process gate and the hosting platform: the readiness probe's HTTP result is driven by one boolean. The readiness-probe concept itself is taught at WarmupReadinessGate, and the tag vocabulary at HealthCheckTags.
  • Walkthrough: A single method, CheckHealthAsync(HealthCheckContext, CancellationToken) (WarmupReadinessHealthCheck.cs:11-16), returning Task.FromResult(gate.IsReady ? HealthCheckResult.Healthy("Warm-up complete.") : HealthCheckResult.Unhealthy("Warm-up in progress.")). It is synchronous under an async signature because reading a volatile int involves no I/O; Task.FromResult avoids a state machine entirely.
  • Why it's built this way: internal sealed, since the framework wires it. Keeping it trivially cheap (no I/O, no allocation beyond the result) means the platform can poll the readiness endpoint frequently without cost, which matters because probe intervals are measured in seconds. The two description strings are what an operator sees in the /health/ready payload, so they are written for a human reading a probe failure.
  • Where it's used: Registered as the "warmup" check tagged HealthCheckTags.Ready by AddWarmupReadiness (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:115-116); surfaced at /health/ready by MapDefaultEndpoints (Extensions.cs:433-435), whose predicate admits every check tagged neither live nor optional (:435). Covered by WarmupReadinessHealthCheckTests.

SecurityHeadersMiddleware

MMCA.Common.Aspire · MMCA.Common.Aspire.Security · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Security/SecurityHeaders.cs:133 · Level 2 · class (sealed)

  • What it is: ASP.NET Core middleware that adds hardened security response headers to every response: X-Content-Type-Options: nosniff, X-Frame-Options, Referrer-Policy, Permissions-Policy, HSTS (outside Development), and a CSP resolved from ICspPolicyProvider, including per-request nonce substitution.
  • Depends on: SecurityHeadersSettings, ICspPolicyProvider, CspPolicy, CspNonce; RequestDelegate, IWebHostEnvironment, IOptions<T> (ASP.NET Core), and RandomNumberGenerator (System.Security.Cryptography).
  • Concept introduced, centralized security headers as shared middleware. [Rubric §11, Security] (X-Frame-Options, CSP, HSTS, Referrer-Policy, Permissions-Policy, all defence in depth), [Rubric §17, DevOps & Deployment] (the doc comment at SecurityHeaders.cs:130-131 states it centralizes what each client-facing host previously hand-rolled), [Rubric §26, Front-End Security] (CSP restricting content sources, reducing the impact of injected script on Blazor pages).
  • Walkthrough:
    • Two private constants frame the nonce feature: NoncePlaceholder = "{nonce}" (:136), the literal token a policy uses to request one, and NonceByteCount = 16 (:139), whose doc comment records that 16 bytes (128 bits) is the CSP specification's recommendation.
    • Four readonly fields (:141-144). The constructor (:147) null-guards options and environment (:153-154), captures the next delegate, the CSP provider, and the settings snapshot (:155-157), and computes _enableHsts = options.Value.EnableHsts && !environment.IsDevelopment() (:158) so HSTS is never emitted in development, where it would pin localhost to HTTPS in the browser for a year.
    • InvokeAsync (:162) null-guards the context (:164), takes the response header collection once (:166), and sets XContentTypeOptions = "nosniff" (:167), XFrameOptions (:168), Referrer-Policy (:169), and Permissions-Policy (:170); it conditionally sets StrictTransportSecurity (:172-175).
    • It then asks the provider for a CspPolicy (:177) and, when the result is non-null (:178), runs the nonce path: if the value contains {nonce} (:184), a fresh Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)) value is generated (:186), stored under CspNonce.ItemKey in HttpContext.Items (:187), and substituted into every occurrence as 'nonce-<value>' (:188). The comment above it (:180-182) records why the raw value lands in Items before the pipeline runs: the page render is what stamps it onto its script and style tags. Finally the header is written as ContentSecurityPolicy when Enforce is true (:193) and ContentSecurityPolicyReportOnly otherwise (:197).
    • The last statement is await _next(context).ConfigureAwait(false) (:201). Note the ordering: every header is written before the rest of the pipeline runs, which is what makes them survive on responses produced further down, including forwarded YARP responses, and what makes the nonce readable by the render.
  • Why it's built this way: A sealed conventional middleware (constructor plus InvokeAsync), not an IMiddleware, so it is a singleton per pipeline with zero per-request resolution. Resolving CSP through the injected ICspPolicyProvider rather than reading settings directly is what makes per-host dynamic policies possible while every other header stays uniform. Computing _enableHsts once in the constructor avoids re-checking the environment on every request, and the nonce generation is gated on the placeholder being present, so a policy that does not ask for one pays no cryptographic-RNG call at all. This single middleware is ADR-023's "one hardened default, defined once" decision: centralising the header set removes the per-host drift between Gateway and UI (and between apps) that came from each edge host hand-rolling its own headers, and makes a new edge host secure by default.
  • Where it's used: Added to the pipeline by SecurityHeadersExtensions.UseCommonSecurityHeaders (SecurityHeaders.cs:248). Per ADR-023 it is wired at both edges of both apps: MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:134, MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:108, MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:159, and MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:145. Covered by SecurityHeadersMiddlewareTests in MMCA.Common.Aspire.Tests, whose nonce cases assert that every placeholder occurrence is replaced (MMCA.Common/Tests/Hosting/MMCA.Common.Aspire.Tests/Security/SecurityHeadersMiddlewareTests.cs:131-133), and by the shared per-host SecurityHeadersTestsBase that both Gateway test projects subclass.
  • Caveats / not-in-source: The headers are set on the way in, so a downstream component that removes or overwrites Response.Headers after this point wins; nothing here re-asserts them on the way out.

StaticCspPolicyProvider

MMCA.Common.Aspire · MMCA.Common.Aspire.Security · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Security/SecurityHeaders.cs:79 · Level 2 · class (sealed, internal)

  • What it is: The default ICspPolicyProvider: it returns the static CSP configured in SecurityHeadersSettings, or null when none is configured.
  • Depends on: ICspPolicyProvider (implements), SecurityHeadersSettings (via IOptions<T>), CspPolicy (returns).
  • Concept introduced: Cross-reference ICspPolicyProvider for the provider extension point. [Rubric §11, Security]: this is the safe fallback, so a host that registers nothing still gets the hardened baseline rather than no CSP at all.
  • Walkthrough: One readonly CspPolicy? _policy field (SecurityHeaders.cs:81). The constructor (:83) null-guards options (:85), reads options.Value.ContentSecurityPolicy (:86), and sets _policy to null when that string is null or whitespace, otherwise to a new CspPolicy capturing the string and the EnforceContentSecurityPolicy flag (:87-89). GetPolicy(HttpContext context) (:92) simply returns the cached _policy, ignoring the request context, which is exactly what "static" means here.
  • Why it's built this way: internal sealed, since it is the framework's own default and is registered by TryAddSingleton so a custom provider registered first by a Blazor host wins. Computing the policy once in the constructor rather than per request makes GetPolicy allocation-free on the hot path, which matters because it runs on every single response. Per ADR-023 this static baseline is now a complete hardened policy rather than a partial one, correct for JSON, WebSocket, and static hosts (API, Gateway) and functional for an HTML host that never registers a fuller provider.
  • Where it's used: Registered by SecurityHeadersExtensions.AddCommonSecurityHeaders via TryAddSingleton (SecurityHeaders.cs:237); resolved per request by SecurityHeadersMiddleware (:177). Its baseline output is asserted by SecurityHeadersMiddlewareTests.
  • Caveats / not-in-source: Because the policy is captured in the constructor from IOptions<T> rather than IOptionsMonitor<T>, a configuration reload of the "SecurityHeaders" section does not change the live CSP: that takes a restart.

SecurityHeadersExtensions

MMCA.Common.Aspire · MMCA.Common.Aspire.Security · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Security/SecurityHeaders.cs:210 · Level 3 · class (static)

  • What it is: The registration and pipeline extensions for the common security-headers middleware: AddCommonSecurityHeaders (DI) and UseCommonSecurityHeaders (pipeline).
  • Depends on: SecurityHeadersSettings, ICspPolicyProvider, StaticCspPolicyProvider, SecurityHeadersMiddleware.
  • Concept reinforced, middleware registration via extension members. [Rubric §11, Security] (HTTP security headers) and [Rubric §26, Front-End Security] (CSP defending the Blazor front end). The two-call shape, AddCommonSecurityHeaders during service registration and UseCommonSecurityHeaders in the pipeline, is the idiomatic ASP.NET Core split, expressed here with two extension(T) blocks in one static class (SecurityHeaders.cs:212, :242), which is also why the class carries the scoped CA1708 suppression with its false-positive justification (:206-209).
  • Walkthrough:
    • AddCommonSecurityHeaders(configuration = null, configure = null) (SecurityHeaders.cs:220): null-guards the receiver (:224), builds an AddOptions<SecurityHeadersSettings>() (:226), binds the "SecurityHeaders" configuration section when configuration is supplied (:228-230), applies the optional configure delegate when supplied (:232-235), then calls TryAddSingleton<ICspPolicyProvider, StaticCspPolicyProvider>() (:237). The TryAdd is the key detail: a host that registered a custom provider before this call keeps it; otherwise the static default is used. Both parameters are optional, so a host can take the hardened defaults with a bare AddCommonSecurityHeaders().
    • UseCommonSecurityHeaders() (:245): null-guards the app (:247) and returns app.UseMiddleware<SecurityHeadersMiddleware>() (:248). Call it early in the pipeline so headers land on every response, including forwarded ones.
  • Why it's built this way: A static class with extension(T) blocks is the codebase's standard DI and registration idiom (see primer, C# extension(T) types and ADR-106). The TryAddSingleton ordering contract is what makes the per-consumer CSP override work without a configuration flag or a builder API. ADR-023 documents the matching foot-gun: because the default provider is TryAdd-registered, a host must register its custom ICspPolicyProvider before calling AddCommonSecurityHeaders, or the static default silently wins.
  • Where it's used: Called by each client-facing host: MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:84, MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:103, MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:71, MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:140. The two UI hosts call AddCommonBlazorCsp() first (ADC Program.cs:99, Store Program.cs:139, each with the ordering comment right above it at ADC :97-98 and Store :137-138) so BlazorCspPolicyProvider wins over the StaticCspPolicyProvider default.
  • Caveats / not-in-source: Nothing enforces the registration order at build time; the ordering contract is documented in the doc comments, in both hosts' inline comments, and in ADR-023, but a host that got it backwards would silently ship the static baseline instead of its own policy.

BrokerResilienceDefaults

MMCA.Common.Shared · MMCA.Common.Shared.Resilience · MMCA.Common/Source/Core/MMCA.Common.Shared/Resilience/BrokerResilienceDefaults.cs:24 · Level 0 · class (static)

  • What it is: four static properties that are the single source of truth for the circuit breaker guarding the outbox's broker-publish call: the failure ratio that opens the circuit, the minimum number of attempts before that ratio is judged, the rolling window it is judged over, and how long the circuit stays open.
  • Depends on: nothing first-party, and nothing beyond TimeSpan, double and int (BCL). Read by OutboxProcessor in MMCA.Common.Infrastructure. It is the deliberate sibling of HttpResilienceDefaults, and the class doc says so (MMCA.Common/Source/Core/MMCA.Common.Shared/Resilience/BrokerResilienceDefaults.cs:5-7): both live in Shared "so the numbers are reviewable in one place rather than buried as literals inside a background service".
  • Concept introduced, a breaker that shortens discovery rather than protecting a dependency. [Rubric §29, Resilience & Business Continuity], [Rubric §12, Performance & Scalability], [Rubric §15, Best Practices & Code Quality]. The usual argument for a circuit breaker is to spare a struggling dependency further load. This one is about the caller's wasted time, and the class doc lays out the arithmetic (:8-16): when the broker is down, every publish in a 50-row batch waits out its own transport timeout before failing, so one cycle can spend minutes doing nothing but timing out, and the retry loop queues the same wait again on the next cycle. The breaker turns the second and later attempts into an immediate rejection. Crucially nothing is lost by failing fast: an outbox row that is not published stays unprocessed, keeps its lease-based backoff, and is retried on a later cycle exactly as any other publish failure would be. The breaker only changes how long the processor spends rediscovering that the broker is still down. The second half of the doc (:17-22) is the composition rule worth internalising: it is deliberately not paired with a retry strategy, because the outbox already owns retry (RetryCount, exponential backoff with jitter, MaxRetries then dead-letter), and a Polly retry inside a publish would multiply against that budget and make a row's effective attempt count an accident of two independent policies.
  • Walkthrough: four expression-bodied static properties, each carrying its own justification.
    • FailureRatio => 0.5 (:32): half the publishes in the window must fail before the circuit opens. Half rather than a lower bar because a partially degraded broker still delivering half its messages is worth continuing to drain (:27-31), and only a clearly one-sided failure rate is evidence that further attempts this cycle are wasted.
    • MinimumThroughput => 10 (:40): the minimum publish attempts inside the window before the ratio is evaluated at all. Ten keeps a quiet host, where two attempts an hour is normal traffic, from opening the circuit on a single unlucky pair (:34-39).
    • SamplingDuration => TimeSpan.FromSeconds(30) (:47): the rolling window. Thirty seconds is a few outbox cycles at the default polling interval, long enough that a batch's worth of attempts lands inside one window, short enough that a resolved outage ages out of the statistics quickly (:42-46).
    • BreakDuration => TimeSpan.FromSeconds(15) (:55): how long the circuit stays open before one trial publish is allowed through. Deliberately short, because a rejected publish costs one outbox row a retry increment, not a customer-facing error, so recovery latency matters more here than protecting the broker from one probe (:49-54).
  • Why it's built this way: expression-bodied static properties rather than fields or constants, for the same reason as its sibling: TimeSpan cannot be a const, and a property is evaluated at call time instead of being inlined into the consuming assembly. Living in MMCA.Common.Shared rather than next to the processor is what makes the four numbers reviewable as a policy: a reader sees the whole breaker in one screen, with each value's justification attached to the member it justifies, without opening a 900-line background service. ADR-009 is the governing decision: resilience is a framework invariant, not a per-call choice.
  • Where it's used: OutboxProcessor builds its publish pipeline from all four (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Processing/OutboxProcessor.cs:757-768, values at :759-762), holds it in an instance field (:99) rather than a static one so parallel test processors cannot open each other's circuit (field doc comment :87-98), and logs an opening through LogBrokerCircuitOpen (:664). The pipeline also excludes OperationCanceledException from the handled set (:763-764), so a host shutdown cancelling a batch mid-flight is not counted as broker unhealthiness. Circuit openings surface as telemetry through the MMCA.Common.Broker meter that Common.Aspire Extensions subscribes by literal name (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:203).
  • Caveats / not-in-source: the four values are not configurable. There is no settings class and no configuration key, so tuning them is a framework release. The breaker guards the broker-publish call only, never the database calls, which the processor's field doc states outright (OutboxProcessor.cs:89-91): a breaker on those would open exactly when the processor most needs to persist retry state.

ForwardedHeadersExtensions

MMCA.Common.Gateway · MMCA.Common.Gateway · MMCA.Common/Source/Hosting/MMCA.Common.Gateway/ForwardedHeadersExtensions.cs:23 · Level 0 · class (static)

  • What it is: a two-member static class that gives a gateway host the framework's forwarded-headers wiring: one extension(IApplicationBuilder) method, UseCommonForwardedHeaders(), and one public factory, CreateForwardedHeadersOptions(), that builds the options both the gateway and the service pipeline use.
  • Depends on: nothing first-party. Microsoft.AspNetCore.Builder.IApplicationBuilder and Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersOptions / ForwardedHeaders (ASP.NET Core). It sits beside the rest of the gateway kit and is consumed alongside GatewayReverseProxyExtensions.
  • Concept introduced, forwarded headers as a correctness precondition for the edge limiter. [Rubric §11, Security] assesses whether the app can identify its caller correctly; [Rubric §17, DevOps & Deployment] assesses whether shared request concerns are composed once rather than per host. Behind a cloud reverse proxy the TCP peer is the proxy, not the user. The class doc names the consequence precisely (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/ForwardedHeadersExtensions.cs:13-16): behind Azure Container Apps ingress every connection arrives from the ingress proxy's IP, so an unforwarded gateway collapses the per-client-IP rate-limit partition into one shared window for every real user. That makes this middleware a precondition for GatewayRoutePolicySettings partitioning, not an optional nicety, which is why the doc states the ordering contract explicitly (:29-32): call it first in a gateway pipeline, before the edge rate limiter.
  • Walkthrough:
    • extension(IApplicationBuilder app) block (:25). Its single member UseCommonForwardedHeaders() (:36-41) null-guards the builder (:38) and returns app.UseForwardedHeaders(CreateForwardedHeadersOptions()) (:40), so it is a one-line delegation to the shared options.
    • CreateForwardedHeadersOptions() (:55-66) is where the policy lives. It enables the three headers XForwardedFor | XForwardedProto | XForwardedHost (:59), then clears both allow-lists: options.KnownProxies.Clear() (:62) and options.KnownIPNetworks.Clear() (:63).
    • Clearing is the load-bearing line, and the doc explains why (:47-52): cloud reverse proxies front the app from internal IPs that are in neither default allow-list, so leaving the defaults in place makes the middleware ignore every forwarded header it receives and report the ingress IP as the client. The failure mode is silent: the middleware runs, returns 200s, and reports the wrong address.
    • The class carries a scoped CA1708 suppression (:19-22) with the same justification used elsewhere in the kit: with an extension(T) block inside a static class, the analyzer flags the compiler-generated grouping members as case-colliding (see primer, C# extension(T) types).
  • Why it's built this way: service hosts get this step from UseCommonMiddlewarePipeline in MMCA.Common.API, but a gateway takes none of that package: no controllers, no DbContext, no auth middleware. The doc records that reasoning (:9-17) and the address that follows from it: the one package every MMCA gateway already references, and one whose only runtime dependency is YARP. Without it a gateway hand-rolls the same five lines. This is the gateway half of ADR-088's edge-responsibility set.
  • Where it's used: both consumer gateways call it as the first middleware after Build(): MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:124 (with the ordering rationale in the comment at :119-123) and MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:150. In both hosts the edge limiter (app.UseGatewayRateLimiting(), ADC Program.cs:143, Store Program.cs:170) comes later, which is the contract the doc asks for. Covered by ForwardedHeadersExtensionsTests.
  • Caveats / not-in-source: cleared allow-lists mean the middleware trusts X-Forwarded-* from any peer. That is safe only when the process is genuinely unreachable except through the platform ingress. Nothing in this file asserts that topology; it is a deployment property. Not determinable from source: whether a given environment actually terminates all inbound traffic at a proxy.

GatewayActiveHealthCheckDefaults

MMCA.Common.Gateway · MMCA.Common.Gateway · MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewaySettings.cs:150 · Level 0 · class (sealed, options)

  • What it is: the default active (out-of-band probe) destination health-check block applied to any YARP cluster that declares none. Five init-only properties: whether to apply it at all, the policy name, the probe interval, the per-probe timeout, and the probe path.
  • Depends on: System.ComponentModel.DataAnnotations.RequiredAttribute (BCL) and TimeSpan. Held by GatewayHealthCheckDefaults; materialised into YARP's ActiveHealthCheckConfig by GatewayHealthCheckDefaultsConfigFilter.
  • Concept introduced, probing liveness and never readiness. [Rubric §13, Observability & Operability] assesses whether an operator can tell a healthy deployment from an outage. The Path doc comment (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewaySettings.cs:165-169) is the teachable line in this whole family: the probe targets /alive rather than /health on purpose, because readiness on a downstream flips during its own rolling deployment, and ejecting a destination for that is the gateway reacting to a healthy deployment as if it were an outage. That rule (startup and rollout gates read liveness, never readiness) is the same one HealthCheckTags encodes on the service side.
  • Walkthrough:
    • Enabled (:153): defaults to false, the one place this family is off by default. The class doc gives the cost argument (:145-149): an extra probe per destination per interval is real traffic and real cost, and passive checks already eject a destination that is failing the requests the gateway actually cares about.
    • Policy = "ConsecutiveFailures" (:157), [Required] (:156): YARP's built-in active policy name.
    • Interval = TimeSpan.FromSeconds(10) (:160) and Timeout = TimeSpan.FromSeconds(5) (:163): the probe cadence and the per-probe budget.
    • Path = "/alive" (:171), [Required] (:170).
  • Why it's built this way: off by default with every value pre-filled means a host turns active probing on with a single "Enabled": true and inherits a sane probe, rather than restating five properties. Both consumer gateways do exactly that and override only the interval: ADC sets Enabled: true plus Interval: 00:00:30 (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:42-47, with the reasoning in the comment at :36-41), Store the same (MMCA.Store/Source/Hosts/MMCA.Store.Gateway/appsettings.json:26-31). ADC's comment states the gap active probing closes: passive checks only demote a destination after real traffic has already failed against it, so a restarting service keeps absorbing requests until enough of them error.
  • Where it's used: read by GatewayHealthCheckDefaultsConfigFilter.BuildActive() (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/Configuration/GatewayHealthCheckDefaultsConfigFilter.cs:74-87), which returns null when Enabled is false. Covered by GatewayHealthCheckDefaultsConfigFilterTests.
  • Caveats / not-in-source: [Required] on a property with a non-null default only fails validation when configuration explicitly supplies an empty string; it cannot catch a mistyped policy name, which YARP resolves at proxy-config load time rather than here.

GatewayClusterRequestProfile

MMCA.Common.Gateway · MMCA.Common.Gateway · MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewaySettings.cs:52 · Level 0 · class (sealed, options)

  • What it is: a YARP forwarder request profile expressed in configuration-friendly primitives: HTTP version, version policy, forwarder activity timeout and response-buffering flag, all nullable, plus two internal parsers that turn the two text properties into their strongly typed forms with a cluster-naming error message.
  • Depends on: System.Net.HttpVersionPolicy and System.Version (BCL). Held by GatewaySettings as both ClusterRequestDefaults and the values of ClusterRequestOverrides; merged into YARP's ForwarderRequestConfig by GatewayClusterProfileConfigFilter.
  • Concept introduced, nullable-means-defer, and text-over-type for fail-fast binding. [Rubric §29, Resilience, Reliability & Business Continuity], [Rubric §15, Best Practices & Code Quality]. Two design choices are worth reading together. First, every property is nullable and null means "defer to a lower-precedence source" (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewaySettings.cs:55-57, :61-62). That is what makes the merge in the config filter per property rather than per block: a cluster that states one value keeps inheriting the rest. Second, the version pair is text rather than Version / HttpVersionPolicy, and the class doc says why (:47-50): a mistyped value then fails at startup with a message naming the cluster, instead of binding to a silent default. Configuration binders are forgiving by nature, and a silently defaulted HTTP version is exactly the class of bug that only appears as a connection reset in production. This is the ADR-070 fail-fast posture applied to a binder that would otherwise swallow the error.
  • Walkthrough:
    • Version (:58), for example "2.0" or "1.1"; VersionPolicy (:64), for example "RequestVersionExact" (h2c prior knowledge) or "RequestVersionOrLower"; ActivityTimeout (:67), how long a forwarded request may stay idle before the forwarder aborts it; AllowResponseBuffering (:70).
    • ParseVersion(string clusterId) (:76-90): returns null when Version is unset (:78-81), otherwise System.Version.TryParse (:83) or an InvalidOperationException whose message names the cluster and the two valid shapes (:85-87).
    • ParseVersionPolicy(string clusterId) (:96-111): the same shape over Enum.TryParse<HttpVersionPolicy>(..., ignoreCase: true, ...) (:103), with a message listing all three valid policy names (:105-108).
    • Both parsers are internal, so they are reachable from the config filter and its tests but are not public API. clusterId exists purely for the failure message, which the doc comments state on both (:73, :93).
  • Why it's built this way: it is the copy-paste eliminator described on GatewaySettings's ClusterRequestDefaults (:17-21). A gateway fronting services that all speak h2c on cleartext declares the version pair and the activity timeout once, instead of repeating an identical HttpRequest block per cluster. The exception thrown from a parser propagates out of the YARP config filter during proxy-config load, so a bad value is a startup failure with the cluster id in it rather than a runtime surprise.
  • Where it's used: bound from MmcaGateway:ClusterRequestDefaults and MmcaGateway:ClusterRequestOverrides:{clusterId}. ADC declares a 00:01:40 REST activity timeout as the default and a one-hour override for the long-lived SignalR hub cluster notification-hub (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:28-35); Store declares only the default (MMCA.Store/Source/Hosts/MMCA.Store.Gateway/appsettings.json:23-25). Consumed by GatewayClusterProfileConfigFilter. Covered by GatewayClusterProfileConfigFilterTests and by the binding assertions in AddMmcaGatewayTests.
  • Caveats / not-in-source: the parsers throw rather than returning a Result, which is the framework's usual failure carrier. That is deliberate for a startup-path configuration error (there is no caller who could handle it), but it does mean this type does not follow the Result pattern the rest of the codebase does.

GatewayPassiveHealthCheckDefaults

MMCA.Common.Gateway · MMCA.Common.Gateway · MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewaySettings.cs:132 · Level 0 · class (sealed, options)

  • What it is: the default passive (in-band) destination health-check block applied to any YARP cluster that declares none. Three init-only properties: enabled, policy name, reactivation period.
  • Depends on: RequiredAttribute (BCL) and TimeSpan. Held by GatewayHealthCheckDefaults; materialised by GatewayHealthCheckDefaultsConfigFilter. Structurally the sibling of GatewayActiveHealthCheckDefaults, which introduces the liveness-probe rule.
  • Concept introduced, why passive is the default and active is opt-in. [Rubric §29, Resilience & Business Continuity], [Rubric §31, Cost/FinOps]. The class doc gives the argument in one sentence (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewaySettings.cs:128-131): YARP watches the forwarded responses it is already making, so passive checking costs no extra traffic, which is why it is on by default while active probing is not. The tradeoff is latency of detection: passive only reacts once real traffic has already failed. A gateway that wants earlier detection turns the active block on and pays for the probes.
  • Walkthrough:
    • Enabled = true (:135): the default-on flag, the opposite of its active sibling.
    • Policy = "TransportFailureRate" (:139), [Required] (:138): YARP's built-in passive policy.
    • ReactivationPeriod = TimeSpan.FromSeconds(60) (:142): how long an ejected destination stays out before it is retried.
  • Why it's built this way: the parent's doc states the gap this closes (:114-118): YARP ejects a failing destination only when a cluster carries a health-check block, and a gateway assembled from configuration routinely ships without one, so these defaults turn ejection on for every cluster that stayed silent. Turning ejection on by default is safe precisely because passive checking is free; the same reasoning would not justify defaulting active probes on.
  • Where it's used: read by GatewayHealthCheckDefaultsConfigFilter.BuildPassive() (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/Configuration/GatewayHealthCheckDefaultsConfigFilter.cs:59-70). Neither consumer gateway overrides it, so both run the built-in defaults; ADC's Program.cs comment describes exactly that outcome (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:106-108).
  • Caveats / not-in-source: TransportFailureRate has its own YARP-side thresholds (what failure rate ejects a destination) that are not exposed here. Not determinable from this source: the rate YARP applies when the policy is named without further configuration.

GatewayRoutePolicyPartition

MMCA.Common.Gateway · MMCA.Common.Gateway · MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewaySettings.cs:199 · Level 0 · enum

  • What it is: a two-value enum naming what a named per-route rate-limiter policy counts requests against: ClientIp = 0 (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewaySettings.cs:202) or Global = 1 (:205).
  • Depends on: nothing. Read by GatewayRoutePolicySettings and, through it, by GatewayRoutePolicyExtensions.
  • Concept introduced: none new. It names the two partition strategies for the edge tier of the rate-limiting stack whose settings sibling is GatewayRateLimitingSettings, governed by ADR-019 as extended by ADR-088. [Rubric §11, Security]: the doc comment on ClientIp records why it is the edge default in one clause (:201): at the edge there is no principal yet, because the gateway deliberately does not validate tokens. Per-IP is the only attribution available before authentication happens downstream.
  • Walkthrough: ClientIp = 0 is declared first and is therefore the CLR default for the enum, which lines up with GatewayRoutePolicySettings's own explicit default (:215). Global = 1 is one window for the whole replica, whoever the caller is, which the doc states plainly (:204).
  • Why it's built this way: an enum rather than a boolean makes the configuration file self-describing ("Partition": "ClientIp") and leaves room for a third strategy without a breaking change to the settings shape. Explicit numeric values pin the wire representation so reordering the members later cannot silently change what a persisted integer means.
  • Where it's used: GatewayRoutePolicySettings.PartitionKey(IPAddress?) branches on it (:243-246). Both consumer gateways set "Partition": "ClientIp" explicitly on their auth-tight policy rather than relying on the default (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:50, MMCA.Store/Source/Hosts/MMCA.Store.Gateway/appsettings.json:34).

GatewayTraceHeaderSettings

MMCA.Common.Gateway · MMCA.Common.Gateway · MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewaySettings.cs:178 · Level 0 · class (sealed, options)

  • What it is: the configuration for the two route/cluster trace headers stamped onto each proxied request: whether to stamp them at all, and the name of each. It also owns the two default header-name constants.
  • Depends on: RequiredAttribute (BCL). Held by GatewaySettings and read by GatewayTraceHeaderTransformProvider, which performs the stamping.
  • Concept introduced, naming the route rather than reconstructing it. [Rubric §13, Observability & Operability] assesses whether a request can be followed across processes. The class doc gives the motivation (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewaySettings.cs:174-177): a downstream log line can name the gateway route that produced it without correlating by path pattern. Reconstructing "which route matched" from a path downstream is guesswork the moment two routes share a prefix or a transform rewrote the path; the gateway already knows the answer and simply says it. This is complementary to, not a replacement for, the correlation id GatewayCorrelationMiddleware mints (ADR-041 extended one hop outward by ADR-088): the id answers "which request", these two headers answer "through which edge route".
  • Walkthrough:
    • DefaultRouteHeaderName = "X-MMCA-Route" (:181) and DefaultClusterHeaderName = "X-MMCA-Cluster" (:184): const string defaults, public so tests and hosts can assert against the names rather than re-typing the literals.
    • Enabled = true (:187): stamping is on by default.
    • RouteHeaderName (:191) and ClusterHeaderName (:195), both [Required] (:190, :194) and both initialised from the matching constant, so an operator renaming one header does not have to restate the other.
  • Why it's built this way: defaults-as-constants plus [Required] init-only properties is the shape used throughout this settings file. On by default is safe because the headers carry no user data (a route id and a cluster id are both authored by the gateway operator). Renaming is supported because X-MMCA-* is an MMCA-specific prefix a host embedding the gateway in a wider estate may need to change.
  • Where it's used: bound from MmcaGateway:TraceHeaders; AddMmcaGatewayTests binds a renamed header to prove the section reaches the type (MMCA.Common/Tests/Hosting/MMCA.Common.Gateway.Tests/AddMmcaGatewayTests.cs:67). Neither consumer gateway overrides the names, so both run X-MMCA-Route / X-MMCA-Cluster, which ADC's Program.cs comment describes together with the inbound-value stripping the transform provider performs (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:109-111).
  • Caveats / not-in-source: this type is configuration only. The stripping of any inbound value, which is what makes a downstream able to trust the headers, lives in GatewayTraceHeaderTransformProvider, not here.

HttpResilienceDefaults

MMCA.Common.Shared · MMCA.Common.Shared.Resilience · MMCA.Common/Source/Core/MMCA.Common.Shared/Resilience/HttpResilienceDefaults.cs:10 · Level 0 · class (static)

  • What it is: eight static properties that are the single source of truth for the outbound-HTTP resilience window (attempt timeout, breaker window, total timeout, retry count) and for the SocketsHttpHandler connection hygiene (pool lifetime, idle timeout, keep-alive ping delay and timeout). Two independently packaged transports read them, so the HTTP path and the gRPC path cannot drift apart (MMCA.Common/Source/Core/MMCA.Common.Shared/Resilience/HttpResilienceDefaults.cs:10-44).
  • Depends on: nothing first-party and nothing outside the BCL (TimeSpan, int). All the arrows point at it: the Common.Aspire Extensions service-defaults bootstrap (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:67-70,87-90), the gRPC typed-client DependencyInjection (MMCA.Common/Source/Presentation/MMCA.Common.Grpc/DependencyInjection.cs:113-116), and GrpcResilienceDefaults, which re-exposes four of them.
  • Concept introduced, the shared-constant class as a drift gate. Most of this chapter is about behavior; this type is about agreement. Two packages that ship separately have to configure the same Polly and socket numbers, and nothing in the type system makes them match, so the codebase turns the numbers into a third artifact both are obliged to read. [Rubric §29, Resilience & Business Continuity] assesses graceful degradation under partial failure, and these four Polly values are the shape of that degradation: how long one attempt gets, how wide a window the breaker judges failures over, how long the caller waits in total, and how many times a single hop retries. ADR-009 is the governing decision, and its first clause is exactly this posture: resilience is a framework invariant, not a per-call choice. [Rubric §12, Performance & Scalability] shows up in the retry arithmetic below, because a retry policy is a load multiplier and multipliers compound per hop rather than adding. [Rubric §31, Cost/FinOps] is why the socket-handler half is here at all instead of being left at the BCL defaults: those four values are tuned for an idle Azure Container Apps replica (:30-43). And [Rubric §15, Best Practices & Code Quality] is the file's entire reason to exist: one edit moves both transports, and the alternative was already tried and failed, which the class doc records (:3-9).
  • Walkthrough: eight expression-bodied static properties in two conceptual blocks.
    • The Polly window, the values handed to AddStandardResilienceHandler:
      • AttemptTimeout = 30 s (:13): the budget for one individual HTTP attempt.
      • CircuitBreakerSamplingDuration = 60 s (:16): the rolling window the breaker computes its failure ratio over, so a burst is judged against a minute of traffic rather than against the last handful of calls.
      • TotalRequestTimeout = 90 s (:19): the ceiling on the whole logical call including retries. The three numbers nest: a 30 s attempt inside a 90 s total leaves room for the initial attempt, the one retry, and the backoff between them.
      • MaxRetryAttempts = 1 (:28): retries beyond the initial attempt, deliberately one, and carrying the longest comment in the file (:21-27).
    • Why one retry, the part worth internalizing. The Blazor UI already owns user-facing retries: AuthenticatedServiceBase holds a static Polly policy that retries 3 times with exponential backoff (2 s, 4 s, 8 s) plus up to a second of jitter on HttpRequestException or a retryable response status (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/AuthenticatedServiceBase.cs:19-25), which is up to 4 attempts per user action. If every hop underneath also spent a full retry budget, the attempts would multiply instead of adding: the call-site comment records the previous worst case as 4 outer x 4 inner = 16 gateway hits for one click (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:62-64). That is a retry storm, the failure mode where the load a struggling backend receives goes up precisely because it is failing. One transient-fault retry per hop still absorbs a dropped connection while bounding the multiplier.
    • The socket-handler block, the values handed to SocketsHttpHandler:
      • PooledConnectionLifetime = 10 min (:34): a pooled connection is recycled on this cadence, which forces DNS to be re-resolved, so an ACA replica rollover is picked up without an app restart.
      • PooledConnectionIdleTimeout = 5 min (:37): idle connections stay pooled that long, so low-traffic inter-service calls skip the TCP plus TLS handshake.
      • KeepAlivePingDelay = 60 s (:40) and KeepAlivePingTimeout = 30 s (:43): the HTTP/2 socket-level keep-alive ping interval and the timeout waiting for its acknowledgement.
  • Why it's built this way:
    • The layer rules chose the address. MMCA.Common.Aspire (Hosting) and MMCA.Common.Grpc (Presentation) sit in different layers and neither may reference the other; Shared is the one layer both are allowed to depend on, which the class doc states outright (:6-7). Both project files carry exactly that single ProjectReference, and the Aspire one names this type in a comment as the reason it is there at all (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/MMCA.Common.Aspire.csproj:72-75, MMCA.Common/Source/Presentation/MMCA.Common.Grpc/MMCA.Common.Grpc.csproj:21).
    • It is a remediation, not a premature abstraction. The hand-mirrored copies had already diverged: the gRPC side had fallen back to the 10 s / 30 s library defaults while the HTTP side ran the tuned 30 s / 90 s (:7-8), and the gRPC call site repeats the finding where the mirror lives (MMCA.Common/Source/Presentation/MMCA.Common.Grpc/DependencyInjection.cs:120-126).
    • Properties, not fields or constants. TimeSpan cannot be a const at all, and an expression-bodied static property is evaluated at call time rather than inlined into the consuming assembly the way a const int is at compile time. The flat one-line-per-value shape also keeps the file readable as a ledger. BrokerResilienceDefaults is the same pattern applied to the outbox's broker-publish breaker, and GrpcResilienceDefaults is the same pattern applied to the east-west gRPC path.
  • Where it's used: three call sites, two of which read it.
    • AddServiceDefaults routes it through ConfigureHttpClientDefaults, so every HttpClient in the process inherits it (typed clients, named clients, the YARP forwarder, per the comment at Extensions.cs:53-54): the four Polly values go into AddStandardResilienceHandler (Extensions.cs:65-71) and the four socket values into ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { ... }) (Extensions.cs:84-92).
    • AddTypedGrpcClient<TClient>(serviceName) re-applies the socket block (MMCA.Common/Source/Presentation/MMCA.Common.Grpc/DependencyInjection.cs:110-118) and takes the resilience block from GrpcResilienceDefaults (:106-115). It has to restate the socket values: forcing HTTP/2 means overriding the primary handler, which bypasses the wrapper the global ConfigureHttpClientDefaults installed, so the connection-hygiene values must come from the same source of truth (the reasoning is spelled out at DependencyInjection.cs:101-109).
    • Not a reader: AddTypedServiceClient<TInterface, TImplementation> in Infrastructure (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:833) calls builder.AddStandardResilienceHandler() with no options delegate (:834), so that registration does not itself consume these constants, even though its own doc comment describes its resilience as matching the standard handler from AddServiceDefaults (:803-804). Not determinable from source: how that second standard handler and the global ConfigureHttpClientDefaults pipeline compose into one effective policy on that client at runtime.
  • Caveats / not-in-source:
    • No test pins the numbers. The ADR-009 fitness function ResilienceHandlerTests asserts that AddTypedGrpcClient registers a standard resilience handler at all, and runtime breaker behavior is covered separately by ResilienceCircuitBreakerFaultInjectionTests, which drives its own short values rather than these. A call site that quietly stopped reading a property would still compile.
    • Two handler settings are still duplicated literals rather than centralised here: KeepAlivePingPolicy = HttpKeepAlivePingPolicy.WithActiveRequests and EnableMultipleHttp2Connections = true (Extensions.cs:90-91, DependencyInjection.cs:105,110). The first one matters when reading the ping values: under WithActiveRequests the pings apply to connections that have outstanding requests, so what holds a genuinely idle pooled connection open is PooledConnectionIdleTimeout, not KeepAlivePingDelay.
    • The billing claim is a platform statement. "Does not count as user traffic to the ACA platform" (HttpResilienceDefaults.cs:39), expanded at the call site to idle-vCPU billing roughly 8x cheaper than active (Extensions.cs:78-81), describes how Azure Container Apps meters a replica. Not determinable from source: the code can set a ping interval, it cannot demonstrate how the platform bills it.

GatewayHealthCheckDefaults

MMCA.Common.Gateway · MMCA.Common.Gateway · MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewaySettings.cs:119 · Level 1 · class (sealed, options)

  • What it is: a two-property holder pairing the GatewayPassiveHealthCheckDefaults and GatewayActiveHealthCheckDefaults blocks. It exists so MmcaGateway:HealthCheckDefaults mirrors YARP's own HealthCheck shape one level up.
  • Depends on: its two child settings types. Held by GatewaySettings (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewaySettings.cs:33), read by GatewayHealthCheckDefaultsConfigFilter.
  • Concept introduced: none new; it is the grouping node for the defaults concept the two children teach. [Rubric §29, Resilience & Business Continuity]. The class doc carries the argument for the whole family (:114-118): YARP ejects a failing destination only when a cluster carries a health-check block, and a config-driven gateway routinely ships without one, so these defaults turn ejection on for every cluster that stayed silent. That is the difference between a gateway that load-balances onto a dead replica and one that does not, achieved with zero per-cluster configuration.
  • Walkthrough: Passive { get; init; } = new() (:122), applied only to clusters with no passive block; Active { get; init; } = new() (:125), off unless the host turns it on. Both are non-nullable with eager defaults, so a host that omits the whole HealthCheckDefaults section still gets the passive block.
  • Why it's built this way: shaping the settings after YARP's own HealthCheckConfig (Passive plus Active) means an operator reading the MmcaGateway section recognises it from the ReverseProxy section, and the config filter's merge is a straight per-block copy with no translation table.
  • Where it's used: GatewayHealthCheckDefaultsConfigFilter reads _settings.HealthCheckDefaults.Passive (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/Configuration/GatewayHealthCheckDefaultsConfigFilter.cs:61) and .Active (:76).

GatewayRoutePolicySettings

MMCA.Common.Gateway · MMCA.Common.Gateway · MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewaySettings.cs:212 · Level 1 · class (sealed, options)

  • What it is: one named fixed-window rate-limiter policy a YARP route can reference through YARP's own RateLimiterPolicy route property ("RateLimiterPolicy": "auth-tight"). Four validated init-only properties plus the internal partition-key selector that decides what a given request counts against.
  • Depends on: GatewayRoutePolicyPartition, System.Net.IPAddress, and the RangeAttribute data annotations (BCL). Held by GatewaySettings; turned into a real limiter by GatewayRoutePolicyExtensions.
  • Concept introduced, failing open rather than collapsing a partition. [Rubric §11, Security], [Rubric §14, Testability]. The subtlest member here is PartitionKey, and its doc comment (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewaySettings.cs:235-240) explains a decision that is easy to get backwards: when the client IP is unresolvable and the policy is per-IP, it returns null, meaning no limiter at all. The alternative, bucketing every unattributable request under one shared key, sounds safer but is not: it throttles an in-process TestServer (where RemoteIpAddress is null for every request) to a standstill, and in production it would let one unattributable caller exhaust the window for all the others. Failing open on an unknown address is a bounded loss; collapsing the partition is an unbounded one.
  • Walkthrough:
    • Partition (:215): defaults to GatewayRoutePolicyPartition.ClientIp.
    • PermitLimit = 30 (:219), [Range(1, int.MaxValue)] (:218): requests allowed per window.
    • WindowSeconds = 60 (:223), [Range(1, 3600)] (:222): window length, capped at an hour.
    • QueueLimit (:230), [Range(0, 10_000)] (:229): defaults to zero, and the doc gives the reason (:225-228): a queue at the edge converts a throttle into latency the caller cannot see the cause of. Rejecting immediately with a 429 is an answer the caller can act on.
    • GlobalPartitionKey = "__global" (:233), internal const: the single key used by Partition = Global, and reused as the (unused) key of the no-limiter partition in GatewayRoutePolicyExtensions.Partition (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/RateLimiting/GatewayRoutePolicyExtensions.cs:89).
    • PartitionKey(IPAddress? remoteIpAddress) (:243-246), internal: returns GlobalPartitionKey for a global policy, otherwise remoteIpAddress?.ToString(), which is null when the address is unresolvable. Internal rather than private so the selection is unit-testable without going through a live limiter, which the doc states (:242).
  • Why it's built this way: [Range] annotations rather than hand-written guards, because AddGatewayRoutePolicies runs Validator.ValidateObject(..., validateAllProperties: true) at registration (RateLimiting/GatewayRoutePolicyExtensions.cs:51), so a bad number throws during startup rather than at the first throttled request. That is the ADR-070 fail-fast contract, and the extension's own doc names the ADR (RateLimiting/GatewayRoutePolicyExtensions.cs:34-35). Fixed window rather than sliding or token bucket keeps the configuration two numbers wide, which is what an operator can reason about at the edge.
  • Where it's used: both consumer gateways declare one policy, auth-tight, at 30 requests per 60 s with no queue (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:48-55, MMCA.Store/Source/Hosts/MMCA.Store.Gateway/appsettings.json:32-39), and attach it to their /Auth routes. The Aspire AppHost extension WithE2eGatewayRateLimitLift raises that policy's PermitLimit to 100000 for E2E runs by injecting MmcaGateway__RateLimiterPolicies__auth-tight__PermitLimit (MMCA.Common/Source/Hosting/MMCA.Common.Aspire.Hosting/Extensions.cs:462-464, constants at :60, :77, :84), which is a working demonstration that the whole binding path is environment-overridable. Covered by GatewayRoutePolicyTests.
  • Caveats / not-in-source: the per-IP partition is only as good as the resolved address, which depends on ForwardedHeadersExtensions having run first. Nothing in this type can detect that it did not.

GrpcResilienceDefaults

MMCA.Common.Shared · MMCA.Common.Shared.Resilience · MMCA.Common/Source/Core/MMCA.Common.Shared/Resilience/GrpcResilienceDefaults.cs:12 · Level 1 · class (static)

  • What it is: seven static properties describing the resilience posture of the typed gRPC clients: four that forward straight to HttpResilienceDefaults and three that state the circuit-breaker shape explicitly.
  • Depends on: HttpResilienceDefaults (same namespace) and TimeSpan / double / int (BCL). Read by the gRPC typed-client DependencyInjection.
  • Concept introduced, delegation as a drift gate, and why east-west needs its own breaker. [Rubric §7, Microservices Readiness], [Rubric §29, Resilience & Business Continuity]. Two ideas sit in this one small file. First, the shared values are re-exposed by delegation, not copied: AttemptTimeout, TotalRequestTimeout, SamplingDuration and MaxRetryAttempts are expression-bodied properties that return the HTTP defaults (MMCA.Common/Source/Core/MMCA.Common.Shared/Resilience/GrpcResilienceDefaults.cs:15, :18, :21, :24), so the east-west path cannot drift from the outbound-HTTP path even by accident. Second, the breaker is stated locally on purpose, and the class doc gives the reason (:6-10): east-west gRPC calls address a peer directly and bypass the Gateway's active health checks, so the breaker is the only thing that notices a peer going bad. The gateway's GatewayActiveHealthCheckDefaults protect north-south traffic; nothing protects a service-to-service hop except this.
  • Walkthrough:
    • Delegated (four): AttemptTimeout (:15), TotalRequestTimeout (:18), SamplingDuration (:21, delegating to HttpResilienceDefaults.CircuitBreakerSamplingDuration, note the rename), MaxRetryAttempts (:24). The doc comment on each says "same value as the outbound-HTTP path" so a reader does not have to open the other file to know they agree.
    • Stated (three): FailureRatio => 0.5 (:27), justified because an in-cluster peer is healthy or hard-down, so a tighter ratio would trip on ordinary replica-rollover blips (:26); MinimumThroughput => 10 (:30), so a single failed call against a low-traffic service cannot open the breaker; BreakDuration => TimeSpan.FromSeconds(10) (:33), about one container-replica restart, short enough that recovery is not gated on the breaker.
    • Note the deliberate omission stated in the class doc (:9-10): a gRPC-status-aware retry predicate is not implemented. Retries stay at the HTTP level, where the standard handler already classifies transient faults.
  • Why it's built this way: the same layer reasoning as its sibling. MMCA.Common.Grpc may depend only on MMCA.Common.Shared, so a values class in Shared is the one address both the Aspire hosting bootstrap and the gRPC presentation package can reach. Splitting the file from HttpResilienceDefaults rather than adding three members to it keeps each file readable as one transport's policy, while the delegation keeps them numerically identical where they must be. The three breaker values match BrokerResilienceDefaults's ratio and throughput exactly and differ only in break duration (10 s versus 15 s), which is a consistent framework posture rather than three independently tuned policies.
  • Where it's used: AddTypedGrpcClient<TClient>(serviceName) copies all seven into the standard resilience handler's options (MMCA.Common/Source/Presentation/MMCA.Common.Grpc/DependencyInjection.cs:127-136), with the rationale restated at the call site (:99-105). Covered by ResilienceHandlerTests and ResilienceCircuitBreakerFaultInjectionTests.
  • Caveats / not-in-source: as with its siblings, none of the seven values is configurable, and no test asserts the specific numbers. Changing one is a framework release.

GatewaySettings

MMCA.Common.Gateway · MMCA.Common.Gateway · MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewaySettings.cs:12 · Level 2 · class (sealed, options)

  • What it is: the whole configuration surface of AddMmcaGateway, bound from the MmcaGateway section. Five members: the section-name constant, shared cluster request defaults plus per-cluster overrides, health-check defaults, trace-header settings, and the named rate-limiter policies.
  • Depends on: GatewayClusterRequestProfile, GatewayHealthCheckDefaults, GatewayTraceHeaderSettings, GatewayRoutePolicySettings. Bound and validated by GatewayReverseProxyExtensions; consumed by GatewayClusterProfileConfigFilter, GatewayHealthCheckDefaultsConfigFilter, GatewayTraceHeaderTransformProvider and GatewayRoutePolicyExtensions.
  • Concept introduced, a settings section defined by what it refuses to own. [Rubric §7, Microservices Readiness], [Rubric §15, Best Practices & Code Quality], [Rubric §34, Architecture Governance & Documentation]. The class doc opens with the boundary (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewaySettings.cs:6-11): nothing here duplicates the YARP ReverseProxy section. Routes, clusters and destinations stay where they are, and this section only carries the cross-cutting defaults a gateway would otherwise copy into every cluster by hand. That is the whole design in one sentence, and it is the framework-side expression of ADR-089, whose central finding was that one route table described in three places had already disagreed with itself. A settings type that carried even a partial route list would recreate exactly that failure. It sits beside ADR-088, which enumerates what the gateway does and, deliberately, what it declines to do.
  • Walkthrough:
    • SectionName = "MmcaGateway" (:15): the configuration section, const so a host and its tests can name it without a literal.
    • ClusterRequestDefaults (:22), nullable: the profile applied to every cluster that states no value of its own. The doc calls it "the copy-paste eliminator" (:17-21), the shared activity timeout and h2c version pair declared once instead of per cluster.
    • ClusterRequestOverrides (:29-30): IReadOnlyDictionary<string, GatewayClusterRequestProfile> keyed by cluster id, initialised to an empty case-insensitive dictionary (StringComparer.OrdinalIgnoreCase), because cluster ids in configuration are operator-typed. A cluster whose profile genuinely differs states only the properties that differ (:24-28).
    • HealthCheckDefaults (:33): non-nullable with an eager new(), so the passive defaults apply even when the section is absent entirely.
    • TraceHeaders (:36): likewise eager, so the trace headers are on by default.
    • RateLimiterPolicies (:42-43): keyed by policy name with StringComparer.Ordinal, deliberately case sensitive unlike the cluster overrides, because a route references the policy by exact name through YARP's own RateLimiterPolicy property.
  • Why it's built this way: init-only properties throughout, which is what lets GatewayReverseProxyExtensions hand the whole object to DI as an IOptions<GatewaySettings> instance rather than mutating one into place (its comment says exactly that, MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewayReverseProxyExtensions.cs:73-75). Every collection and child block has a non-null default, so a gateway that adds the package and configures nothing still gets passive health checks and trace headers, which is the "useful with zero configuration" posture the whole kit is built around.
  • Where it's used: bound by AddMmcaGateway(IConfiguration) with .ValidateDataAnnotations().ValidateOnStart() (GatewayReverseProxyExtensions.cs:54-57). Live sections: MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:27-56 and MMCA.Store/Source/Hosts/MMCA.Store.Gateway/appsettings.json:22-40. Covered by AddMmcaGatewayTests, which binds a full section from an in-memory configuration (MMCA.Common/Tests/Hosting/MMCA.Common.Gateway.Tests/AddMmcaGatewayTests.cs:58-86), pins the section name (:100) and asserts the no-section defaults (:88-97).
  • Caveats / not-in-source: ValidateDataAnnotations validates the top-level object; the [Range] annotations on the rate-limiter policies are additionally validated explicitly inside AddGatewayRoutePolicies (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/RateLimiting/GatewayRoutePolicyExtensions.cs:49-52), which is why that loop exists rather than relying on options validation alone.

GatewayClusterProfileConfigFilter

MMCA.Common.Gateway · MMCA.Common.Gateway.Configuration · MMCA.Common/Source/Hosting/MMCA.Common.Gateway/Configuration/GatewayClusterProfileConfigFilter.cs:25 · Level 3 · class (sealed)

  • What it is: a YARP IProxyConfigFilter that resolves each cluster's forwarder request profile from three sources, per property, and rewrites the cluster only when the resolution actually differs from what it already carried.
  • Depends on: GatewaySettings via IOptions<T> and GatewayClusterRequestProfile. Externals: Yarp.ReverseProxy.Configuration.IProxyConfigFilter / ClusterConfig / RouteConfig and Yarp.ReverseProxy.Forwarder.ForwarderRequestConfig, plus Microsoft.Extensions.Options. Registered by GatewayReverseProxyExtensions.
  • Concept introduced, the proxy config filter as a defaults layer. [Rubric §2, Design Patterns], [Rubric §29, Resilience, Reliability & Business Continuity]. YARP loads its route table from a config provider and then runs every registered IProxyConfigFilter over each route and cluster before the table goes live. That hook is a decorator over configuration: it lets a package add behavior to a table it did not author and does not own, which is precisely what makes ADR-089's "the host owns the route table" rule compatible with a shared gateway package. Precedence is per property, not per block, and the class doc calls that out as the point of the type (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/Configuration/GatewayClusterProfileConfigFilter.cs:9-17): a cluster that states one value keeps inheriting the rest. Per-block precedence would force a cluster that only needs a different timeout to also restate its version pair, which is the copy-paste this kit exists to remove.
  • Walkthrough:
    • Primary constructor (IOptions<GatewaySettings> options) (:25) with the settings snapshotted into a readonly field and null-guarded in the initialiser (:27). The filter closes over the value for the process lifetime, which is why the settings type is init-only.
    • ConfigureClusterAsync(ClusterConfig, CancellationToken) (:33-43): null-guards (:35), calls Resolve (:37), then returns the same instance when nothing changed and cluster with { HttpRequest = resolved } otherwise (:39-42).
    • ConfigureRouteAsync(...) (:50-51): returns the route untouched. Routes carry no request profile; only clusters do (:45).
    • Resolve(ClusterConfig) (:59-74), internal so the precedence rules are unit-testable through the filter's own type (:58): reads the cluster's own HttpRequest (:62), looks up the per-cluster override by id (:64), takes ClusterRequestDefaults as the fallback (:65), and builds one ForwarderRequestConfig from four independent resolutions (:67-73).
    • The four private resolvers are one expression each and all read identically, own ?? over ?? fallback: ResolveVersion (:82-87), ResolveVersionPolicy (:95-100), ResolveActivityTimeout (:107-111), ResolveAllowResponseBuffering (:118-122). The two version resolvers call the profile's ParseVersion / ParseVersionPolicy with the cluster id, so a bad string fails with the cluster named.
    • IsSameAs(ForwarderRequestConfig?, ForwarderRequestConfig) (:131-142) is the churn guard and the most easily missed subtlety. It substitutes ForwarderRequestConfig.Empty for an absent block (:136) because, as the inline comment says (:133-135), an absent block and an all-null block mean the same thing to the forwarder, so a cluster that had no profile and gained no defaults must come back untouched rather than acquiring an empty one. It then compares all four properties (:138-141).
  • Why it's built this way: returning the identical instance when nothing changed is what keeps YARP's config-change detection quiet on every reload (:124-127). A filter that always returned a new record would make YARP believe the topology changed on every configuration refresh and churn its route table for nothing. The second doc paragraph (:18-22) records the deliberate absence of a downgrade switch: a resolved HTTP/2 version pair is forwarded as stated, and dropping a cluster to HTTP/1.1 is a per-cluster decision expressed in its own profile.
  • Where it's used: registered by AddMmcaGateway through builder.AddConfigFilter<GatewayClusterProfileConfigFilter>() (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewayReverseProxyExtensions.cs:91). In ADC it replaced the host's own hand-written filter of the same purpose (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:104-106). Covered by GatewayClusterProfileConfigFilterTests.
  • Caveats / not-in-source: the settings are captured once from IOptions<T>, not IOptionsMonitor<T>, so a configuration reload changes the route table YARP reloads but not the defaults this filter applies. Restarting the host is the only way to move them.

GatewayHealthCheckDefaultsConfigFilter

MMCA.Common.Gateway · MMCA.Common.Gateway.Configuration · MMCA.Common/Source/Hosting/MMCA.Common.Gateway/Configuration/GatewayHealthCheckDefaultsConfigFilter.cs:17 · Level 3 · class (sealed)

  • What it is: the second YARP IProxyConfigFilter in the kit. It fills in destination health-check defaults for clusters that declare none, so the proxy actually ejects a failing destination instead of continuing to balance onto it.
  • Depends on: GatewaySettings via IOptions<T>, and through it GatewayHealthCheckDefaults with its GatewayPassiveHealthCheckDefaults and GatewayActiveHealthCheckDefaults children. Externals: YARP's IProxyConfigFilter, ClusterConfig, HealthCheckConfig, PassiveHealthCheckConfig, ActiveHealthCheckConfig. Registered by GatewayReverseProxyExtensions. It shares the config-filter concept introduced by GatewayClusterProfileConfigFilter.
  • Concept introduced, additive-only defaulting. [Rubric §29, Resilience & Business Continuity], [Rubric §34, Architecture Governance & Documentation]. The class doc states the invariant (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/Configuration/GatewayHealthCheckDefaultsConfigFilter.cs:9-14): a cluster that already states a Passive block keeps it verbatim, and the same holds for Active. The filter never edits an operator's explicit choice, it only supplies one where the configuration was silent. That rule is what makes a defaults filter safe to add to an existing gateway: the worst case for a fully configured cluster is that the filter does nothing. Note it is per block, not per property: a cluster that states a partial Passive block does not have the missing properties filled in.
  • Walkthrough:
    • Primary constructor and null-guarded settings field (:17, :19), the same shape as its sibling filter.
    • ConfigureClusterAsync (:25-47): reads the existing HealthCheck (:29), then existing?.Passive ?? BuildPassive() (:30) and existing?.Active ?? BuildActive() (:31). If both resolved to the very same references the cluster already held, it returns the cluster unchanged (:33-36), the same churn guard the profile filter uses, expressed here with ReferenceEquals. Otherwise it rebuilds HealthCheck with the two blocks and carries AvailableDestinationsPolicy across unchanged (:44), so an operator's destination-selection choice survives the rewrite.
    • ConfigureRouteAsync (:54-55): routes carry no health-check configuration; returns the route unchanged.
    • BuildPassive() (:59-70): returns a PassiveHealthCheckConfig from the settings when Enabled, else null. Returning null matters: it means "no passive block", which is different from a block with Enabled = false.
    • BuildActive() (:74-87): the same shape, copying Policy, Interval, Timeout and Path.
  • Why it's built this way: two independent filters rather than one, so each owns exactly one YARP config property. AddMmcaGateway's own doc records the consequence (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewayReverseProxyExtensions.cs:37-41): the profile filter owns HttpRequest, the health-check filter owns HealthCheck, and neither reads what the other wrote, so their relative registration order carries no meaning. That is a real simplification: ordered filters would be a hidden coupling nobody would notice until someone reordered them.
  • Where it's used: registered by AddMmcaGateway (GatewayReverseProxyExtensions.cs:92). With both consumer gateways enabling active probes in configuration, this filter is what turns that flag into a real ActiveHealthCheckConfig on every cluster (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:42-47, MMCA.Store/Source/Hosts/MMCA.Store.Gateway/appsettings.json:26-31). Covered by GatewayHealthCheckDefaultsConfigFilterTests.
  • Caveats / not-in-source: ReferenceEquals on the two blocks is a correct no-op check only because BuildPassive / BuildActive construct new objects whenever they run at all, so a rebuilt block is never reference-equal to an existing one. It is an identity check, not an equivalence check, which is a deliberate difference from the profile filter's property-by-property IsSameAs.

GatewayRoutePolicyExtensions

MMCA.Common.Gateway · MMCA.Common.Gateway.RateLimiting · MMCA.Common/Source/Hosting/MMCA.Common.Gateway/RateLimiting/GatewayRoutePolicyExtensions.cs:27 · Level 3 · class (static)

  • What it is: the registration of the named rate-limiter policies a YARP route references by name. One extension(IServiceCollection) member, AddGatewayRoutePolicies(GatewaySettings), plus the internal static Partition helper that decides what one request counts against.
  • Depends on: GatewaySettings and GatewayRoutePolicySettings. Externals: System.Threading.RateLimiting (RateLimitPartition, FixedWindowRateLimiterOptions, QueueProcessingOrder), Microsoft.AspNetCore.RateLimiting via AddRateLimiter, HttpContext, and Validator from data annotations. Called by GatewayReverseProxyExtensions.
  • Concept introduced, named policies as an additive second limiter. [Rubric §11, Security], [Rubric §17, DevOps & Deployment]. The doc's second paragraph is the operationally important part (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/RateLimiting/GatewayRoutePolicyExtensions.cs:15-21): these policies are additive to any global limiter the host installs (for example MMCA.Common.Aspire.Gateway.AddGatewayRateLimiting, which assigns RateLimiterOptions.GlobalLimiter). ASP.NET Core evaluates the global limiter and the route's named policy independently, so a request must satisfy both. Nothing here overwrites the global limiter, and the two packages stay independent of each other. The division of labour is also worth naming: YARP already knows how to attach a named policy to a route, what it cannot do is create one (:12-14), so this method exists purely to fill that gap without every gateway hand-writing an AddPolicy block.
  • Walkthrough:
    • AddGatewayRoutePolicies(GatewaySettings settings) (:39-69) inside the extension(IServiceCollection services) block (:29).
      • Null-guards both the collection and the settings (:41-42), then short-circuits when no policies are declared (:44-47), so a gateway with no named policies never calls AddRateLimiter at all.
      • Validates every policy up front with Validator.ValidateObject(policy, new ValidationContext(policy), validateAllProperties: true) (:49-52). This runs before any registration, so an out-of-range PermitLimit throws at registration rather than at the first throttled request. The doc names the governing decision (:34-35), ADR-070 fail-fast.
      • Calls AddRateLimiter (:54), sets RejectionStatusCode = StatusCodes.Status429TooManyRequests (:56), and registers one options.AddPolicy<string>(name, ...) per entry (:66), skipping blank names (:60-63). var captured = policy (:65) is the loop-variable capture guard that keeps each registered delegate bound to its own policy.
    • Partition(HttpContext, GatewayRoutePolicySettings) (:81-98), internal so the partition-key selection is unit-testable (:80): asks the policy for the key from httpContext.Connection.RemoteIpAddress (:86), then branches (:88-97). A null key yields RateLimitPartition.GetNoLimiter(...), the fail-open path GatewayRoutePolicySettings explains; otherwise a GetFixedWindowLimiter built from the policy's PermitLimit, WindowSeconds and QueueLimit, with QueueProcessingOrder.OldestFirst and AutoReplenishment = true (:90-97).
  • Why it's built this way: a fixed window is the only limiter shape exposed, which keeps the configuration surface two numbers wide, and AutoReplenishment = true means the window refills on its own timer rather than requiring the host to pump it. RejectionStatusCode is set here rather than left to the host so a throttled request is a 429 regardless of which package installed the limiter. The class carries the same scoped CA1708 suppression as the other extension(T) classes in the kit (:23-26).
  • Where it's used: called from GatewayReverseProxyExtensions.Wire (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewayReverseProxyExtensions.cs:88), so both AddMmcaGateway overloads register it. In the consumer gateways the resulting auth-tight policy is enforced by the single app.UseGatewayRateLimiting() call that also serves the global edge limiter (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:143 with the explanation at :140-142, MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:170). Covered by GatewayRoutePolicyTests.
  • Caveats / not-in-source: AddRateLimiter is additive, so calling it from both this method and a host's own global-limiter registration is safe, but nothing here detects a host that declared policies and then forgot UseRateLimiter(). In that case the policies are registered and simply never applied, with no error.

GatewayReverseProxyExtensions

MMCA.Common.Gateway · MMCA.Common.Gateway · MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewayReverseProxyExtensions.cs:26 · Level 4 · class (static)

  • What it is: the single composition entry point for the gateway kit. Two AddMmcaGateway overloads on IReverseProxyBuilder, one binding from IConfiguration and one taking already-built settings, over one shared private Wire method.
  • Depends on: GatewaySettings, GatewayClusterProfileConfigFilter, GatewayHealthCheckDefaultsConfigFilter, GatewayRoutePolicyExtensions, GatewayTraceHeaderTransformProvider. Externals: Yarp.ReverseProxy (IReverseProxyBuilder, AddConfigFilter, AddTransforms), Microsoft.Extensions.Options, Microsoft.Extensions.Configuration.
  • Concept introduced, a composition root defined by what it declines to load. [Rubric §7, Microservices Readiness], [Rubric §3, Clean Architecture], [Rubric §34, Architecture Governance & Documentation]. The class doc is explicit (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewayReverseProxyExtensions.cs:15-20): it deliberately does not load the route table. LoadFromConfig, or any other IProxyConfigProvider, stays the host's call, because which section owns the routes, and whether they come from configuration at all, is a host decision. This method adds behavior on top of whatever config source the host chose. That single restraint is what reconciles a shared framework package with ADR-089's consumer-owned route table, and ADR-088 records the same package boundary from the edge-responsibility side.
  • Walkthrough:
    • extension(IReverseProxyBuilder builder) (:28) holding both public overloads.
    • AddMmcaGateway(IConfiguration configuration) (:47-60): null-guards (:49-50), takes the MmcaGateway section (:52), registers AddOptions<GatewaySettings>().Bind(section).ValidateDataAnnotations().ValidateOnStart() (:54-57), then calls Wire with section.Get<GatewaySettings>() ?? new GatewaySettings() (:59). The eager Get<T> is necessary because the named rate-limiter policies must be enumerated at registration time, before any IOptions<T> can be resolved.
    • AddMmcaGateway(GatewaySettings settings) (:68-79): for a host composing its settings in code. It registers an explicit IOptions<GatewaySettings> singleton via Options.Create(settings) (:76) rather than a Configure<T> callback, and the inline comment says why (:73-75): GatewaySettings is init-only by design, since the filters close over it for the process lifetime, so the value has to be supplied whole rather than mutated into place.
    • Wire(IReverseProxyBuilder, GatewaySettings) (:86-94), private: calls AddGatewayRoutePolicies(settings) on the service collection (:88), then chains AddConfigFilter<GatewayClusterProfileConfigFilter>() (:91), AddConfigFilter<GatewayHealthCheckDefaultsConfigFilter>() (:92) and AddTransforms<GatewayTraceHeaderTransformProvider>() (:93).
    • The method's <remarks> carry the two rules a reader needs (:36-46): the two config filters are independent so their relative order carries no meaning, and this registers services and maps nothing, so the host still calls MapReverseProxy(), and UseRateLimiter() if it declared any named policies.
  • Why it's built this way: one entry point rather than four opt-in calls means a gateway gets the whole coherent set or none of it, which is what keeps two consumer gateways behaving identically. The two overloads exist because configuration binding and in-code composition are both legitimate: tests and in-process hosts build settings directly, deployed gateways bind a section. Both funnel through Wire, so there is exactly one list of what the kit installs. The scoped CA1708 suppression (:22-25) is the same extension(T) analyzer false positive documented across the kit.
  • Where it's used: both consumer gateways chain it between LoadFromConfig and AddServiceDiscoveryDestinationResolver: MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:112-116 (with a numbered summary of what it adds at :98-111) and MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:138-141 (summary at :122). Covered by AddMmcaGatewayTests, which asserts that one call registers both config filters and the transform provider (MMCA.Common/Tests/Hosting/MMCA.Common.Gateway.Tests/AddMmcaGatewayTests.cs:19-39) and that the settings overload makes them resolvable (:41-56).
  • Caveats / not-in-source: the configuration overload reads the section twice, once through the options pipeline and once eagerly via Get<T>. The eager read is the one the named rate-limiter policies come from, so a policy added by a later configuration reload would not be registered. Nothing in source signals that; the overload's contract is a startup-time snapshot.

GatewayTraceHeaderTransformProvider

MMCA.Common.Gateway · MMCA.Common.Gateway.Transforms · MMCA.Common/Source/Hosting/MMCA.Common.Gateway/Transforms/GatewayTraceHeaderTransformProvider.cs:18 · Level 3 · class (sealed)

  • What it is: The gateway kit's one request transform. It stamps two headers onto every proxied request, X-MMCA-Route with the route id YARP matched and X-MMCA-Cluster with the cluster it selected, after first removing whatever the client sent under those names.
  • Depends on: GatewaySettings, injected as IOptions<GatewaySettings> and reduced immediately to its GatewayTraceHeaderSettings sub-object (GatewayTraceHeaderTransformProvider.cs:18-21). Externals: YARP's ITransformProvider, TransformBuilderContext, TransformRouteValidationContext / TransformClusterValidationContext and the AddRequestTransform extension (Yarp.ReverseProxy.Transforms / .Transforms.Builder, :1-3), plus Microsoft.Extensions.Options. Registered by GatewayReverseProxyExtensions alongside GatewayClusterProfileConfigFilter and GatewayHealthCheckDefaultsConfigFilter.
  • Concept introduced, a header a downstream can trust. [Rubric §11, Security], [Rubric §13, Observability & Operability], [Rubric §7, Microservices Readiness]. There are two ideas here and the second is the one that matters. The first is a YARP extension point: an ITransformProvider is asked to contribute transforms into a route's pipeline when that pipeline is built, which is how a gateway adds behaviour to every route without listing it in each route's JSON. The second is the trust question. Correlating a downstream log line back to a gateway route by matching URL path patterns stops working the moment two routes share a prefix, so the proxy stamps the answer it alone knows. But the instant a downstream trusts a header, that header becomes an input an attacker can supply: a client that sets X-MMCA-Route: identity-auth itself would have its request attributed to a route it never matched. §11 is the category that cares, and the fix is structural rather than validating: the transform removes both header names before writing its own (:60-61), so the value a service reads can only have come from the gateway. The class doc states the rule directly, "A header a service trusts must be one only the gateway can set" (:11-15). §7 is why the fact is worth having at all: once modules run as separate deployables (ADR-008), route selection is a decision made in one process and consumed in the logs of another.
  • Walkthrough:
    • A primary constructor takes IOptions<GatewaySettings> and the single field _settings is initialized to options.Value.TraceHeaders with an inline ArgumentNullException guard (:18-21). The provider therefore holds the trace-header sub-settings only, resolved once at construction: the DI registration supplies IOptions<GatewaySettings> as a fixed Options.Create(settings) singleton (GatewayReverseProxyExtensions.cs:191), so there is no reload path to honour.
    • ValidateRoute (:25) and ValidateCluster (:33) are deliberately empty, each with a comment saying why (:27-28, :35): the provider is applied to every route unconditionally and reads nothing from the route's own transform list, so there is no operator-supplied parameter that could be mistyped and therefore nothing to validate. YARP requires the two methods; implementing them as no-ops with a stated reason is the honest form.
    • Apply(TransformBuilderContext context) (:40) null-guards the context (:42), then applies the kill switch: when _settings.Enabled is false it returns having added nothing (:44-47). Disabled means no transform in the pipeline at all, not a transform that runs and does nothing.
    • Four values are hoisted into locals before the transform delegate is created: the two configured header names and the route's RouteId and ClusterId (:51-54). The comment above them (:49-50) is the rationale: these ids are fixed for the lifetime of this route's transform pipeline, so resolving them per request would be work that can never produce a different answer. The captured locals are what keep the per-request delegate to a few header operations.
    • context.AddRequestTransform(...) (:56) registers the delegate. Inside it: take transformContext.ProxyRequest.Headers (:58), Remove both header names (:60-61), then TryAddWithoutValidation each id back only when it is non-empty (:63-71), and return ValueTask.CompletedTask (:73). The empty checks are not defensive noise: a direct-response route has no cluster, and the behaviour there is to stamp the route header and leave the cluster header absent rather than emit a blank one.
  • Why it's built this way: Remove-then-add rather than "set" is the whole security property, and doing it inside the request transform (which shapes the outbound message to the destination) rather than in middleware is what guarantees no later stage re-copies the inbound value. TryAddWithoutValidation skips the header-syntax checks, which matters because route and cluster ids come from operator-authored configuration and a strict Add would throw inside the proxy pipeline for an id containing an unexpected character. This transform is also a deliberate, documented exception to the gateway kit's own charter: ADR-088 originally declined all request rewriting and was narrowed on 2026-08-27 to allow exactly this one, on the grounds that it stamps which route and cluster YARP selected, "a fact only the proxy knows and one a downstream cannot reconstruct". The declines that still stand are path rewriting, body rewriting and response shaping.
  • Where it's used: Registered once, in the private Wire body shared by both AddMmcaGateway overloads, as .AddTransforms<GatewayTraceHeaderTransformProvider>() (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewayReverseProxyExtensions.cs:93). Both gateway hosts pick it up through that one call: MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:114 and MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:140. Covered directly by GatewayTraceHeaderTransformProviderTests, whose five cases pin exactly the behaviours above: the happy path (GatewayTraceHeaderTransformProviderTests.cs:18), that a client-supplied value is replaced rather than appended to (:29, asserting a single header value at :39-42), custom header names (:46), that Enabled = false leaves context.RequestTransforms empty (:65-75), and the cluster-less route (:81-88). Its presence in the registration is asserted by AddMmcaGatewayTests (AddMmcaGatewayTests.cs:37), and RecordingHttpForwarderTests models the same stamping in its test double (RecordingHttpForwarderTests.cs:159-169).
  • Caveats / not-in-source: Neither app's configuration overrides the MmcaGateway:TraceHeaders section, so the shipped names are the defaults X-MMCA-Route and X-MMCA-Cluster (GatewaySettings.cs:181, :184) and Enabled stays true (GatewaySettings.cs:187). Nothing in this file consumes the headers: what a downstream does with them is out of scope here, and source shows no first-party reader of either name. The settings snapshot is taken in the constructor and the Enabled gate is evaluated when a route's transform pipeline is built, so a configuration change requires a new proxy configuration build, not merely the next request.

OutboxPollFilterProcessor

MMCA.Common.Aspire · MMCA.Common.Aspire.Telemetry · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Telemetry/OutboxPollFilterProcessor.cs:15 · Level 9 · class (sealed)

  • What it is: An OpenTelemetry span processor that marks outbox poll spans, and everything nested under them, as not-recorded so the exporters drop them. It is the framework's answer to a fleet that polls its outbox tables around the clock and would otherwise pay to ingest telemetry about doing nothing.
  • Depends on: OpenTelemetry's BaseProcessor<Activity> and System.Diagnostics.Activity / ActivityTraceFlags (OutboxPollFilterProcessor.cs:1-2). No first-party types, on purpose (see the walkthrough). Conceptually it is paired with OutboxProcessor, whose spans it filters, and it is registered by the service-defaults Extensions.
  • Concept introduced, suppressing a span without breaking the trace. [Rubric §13, Observability & Operability], [Rubric §31, Cost & FinOps]. OpenTelemetry gives every Activity a Recorded flag, and the SDK's batch export processors skip anything unrecorded. Clearing that flag in an earlier processor's OnEnd is therefore how you drop a span from export without interfering with its creation, its timing, or its parent-child links, which is what you want when the span is real work that simply is not worth paying to store. §31 is the category that makes this necessary rather than nice: the OutboxProcessor polls every relational outbox on a recurring cycle, and in a deployed environment the Azure Monitor distro's automatic instrumentation adds a SqlClient dependency span for each of those queries. Idle polls would dominate Application Insights and Log Analytics ingestion and spam the local Aspire dashboard, for zero diagnostic value. §13 is the constraint on the fix: filtering must not blind you to real outbox work, and it does not, because per-message OutboxProcess spans are started with an explicit parent context restored from the trace and span ids stored on the message (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Processing/OutboxProcessor.cs:784-790) and so are never descendants of a poll span. ADR-041 records the policy this implements: instrument where auto-instrumentation is blind, then add cost knobs whose defaults never go dark.
  • Walkthrough:
    • Two private const string fields, OutboxActivitySourceName = "MMCA.Common.Outbox" and PollActivityName = "OutboxPoll" (:23-24). They are duplicated literals, not references to the Infrastructure constants, and the comment above them (:17-22) is the design rationale: MMCA.Common.Aspire does not reference MMCA.Common.Infrastructure, so AddServiceDefaults() stays usable from a host that never takes the persistence stack (EF Core, MassTransit, SignalR/Redis). Its one project reference is MMCA.Common.Shared, for HttpResilienceDefaults. The same two literals also appear in the AddMeter / AddSource calls in Extensions.cs. The counterparts they must stay in sync with are OutboxProcessor.PollActivityName (OutboxProcessor.cs:75) and the activity source new("MMCA.Common.Outbox") (OutboxProcessor.cs:87).
    • OnEnd(Activity data) (:27) is the single override. It opens with a null check that returns instead of throwing (:29-33), under the comment "Never throw from a telemetry callback": an exception escaping an SDK callback is an observability feature taking the process down.
    • The match is a loop up the in-process parent chain, for (var current = data; current is not null; current = current.Parent) (:37). Walking rather than checking only the immediate parent is what catches grandchildren: the SqlClient span the Azure Monitor distro creates can sit more than one level below the poll.
    • The predicate tests both OperationName == PollActivityName and Source.Name == OutboxActivitySourceName (:39-40). The comment (:35-36) names the failure it guards against: a consumer span that happens to be called OutboxPoll from some other source must not be swept up.
    • On a match it clears the flag with data.ActivityTraceFlags &= ~ActivityTraceFlags.Recorded and returns (:45-47). The comment (:42-44) states the ordering contract that makes it work: the batch export processors skip an unrecorded activity, and this processor is registered before the exporters so its OnEnd runs first.
  • Why it's built this way: Sealed, stateless, and registered as a single instance. The literal duplication is a deliberate, commented trade of a small sync obligation against an assembly dependency that would force every service-defaults consumer to take the persistence stack. Clearing Recorded rather than declining to start the activity keeps the poll span available to a local debugger and to any processor registered before this one, and keeps the parent chain intact for the spans that are exported. Returning on a null activity rather than asserting follows the rule the rest of the telemetry code obeys: a cost knob must never be able to fail the request path. ADR-041 documents this as one of three cost levers whose defaults fail safe, alongside head-based sampling and the two metric kill switches.
  • Where it's used: Registered once, inside ConfigureOpenTelemetry's WithTracing block (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:207), as .AddProcessor(new Telemetry.OutboxPollFilterProcessor()) (Extensions.cs:246), immediately after .AddSource("MMCA.Common.Outbox") (:210) and before builder.AddOpenTelemetryExporters() (:265). Every host that calls AddServiceDefaults() gets it, with no opt-in and no configuration key. The spans it targets are opened in two places in the outbox: the candidate fetch (OutboxProcessor.cs:419) and the saturated-batch backlog COUNT (OutboxProcessor.cs:366), both using var pollActivity = OutboxActivitySource.StartActivity(PollActivityName). Covered by OutboxPollFilterProcessorTests, which drives a real ActivityListener and asserts each behaviour: the poll span itself is unrecorded (OutboxPollFilterProcessorTests.cs:43), a child is (:54), a grandchild is (:68), an unrelated root and a same-name span from a different source stay recorded (:81, :92), a real OutboxProcess span stays recorded (:104), and a null activity does not throw (:115).
  • Caveats / not-in-source: The walk is over Activity.Parent, which is the in-process chain only. A span whose parent exists only as a propagated remote context is not matched, so the suppression covers exactly what the poll starts inside the same process, which is what the SqlClient child is. Suppression is unconditional: there is no configuration key to keep poll spans for a debugging session, so diagnosing the poll path itself means reading the outbox meters or temporarily removing the processor. Source shows nothing that verifies the two duplicated literals against OutboxProcessor's constants at build or test time, so the sync obligation in the comment (:17-19) is honoured by review, not by the compiler.

ProbeTelemetryFilter

MMCA.Common.Aspire · MMCA.Common.Aspire.Telemetry · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Telemetry/ProbeTelemetryFilter.cs:27 · Level 9 · class (internal static)

  • What it is: The two predicates the OpenTelemetry instrumentations call to decide whether a request is worth tracing. ShouldCollectRequest refuses an inbound health-probe request and leaves a marker tag behind on the way out; ShouldCollectOutgoing refuses an outbound call to a probe endpoint. Together they are the front half of the Telemetry:FilterProbeTelemetry cost knob, the back half being ProbeTelemetryFilterProcessor.
  • Depends on: HealthEndpointPaths, which owns the one definition of what counts as a probe path (ProbeTelemetryFilter.cs:40, :61). Externals: System.Diagnostics.Activity / ActivityKind and Microsoft.AspNetCore.Http.HttpContext (:1-2), plus HttpRequestMessage for the outbound predicate. Wired into the instrumentation options by the service-defaults Extensions; the marker constant it publishes is read by ProbeTelemetryFilterProcessor.
  • Concept introduced, filtering at the instrumentation rather than at the exporter. [Rubric §31, Cost & FinOps], [Rubric §13, Observability & Operability]. OutboxPollFilterProcessor taught the exporter-side move: let the span exist, then clear its Recorded flag so the batch exporters skip it. OpenTelemetry offers an earlier lever as well. Both the ASP.NET Core and the HttpClient instrumentations expose a predicate (Filter, FilterHttpRequestMessage) consulted at request start, and returning false stops the instrumentation from collecting the span at all: no attributes are written, no enrichment runs. §31 is why it is worth reaching for. Health probes are pure infrastructure chatter (Container Apps liveness and readiness probes, the gateway's downstream aggregate probes, YARP active health checks and the availability web test), and the class doc records that together they accounted for every AppRequests row in both production workspaces (:6-12). They are also invisible to the sampling knob: head-based sampling keeps a proportion of everything, so a TracesSampleRatio of 0.1 keeps a tenth of the probe noise too. §13 is the constraint on the fix, and it shows up as a deliberate scope limit: the filter touches traces only. Metrics are untouched, so http.server.request.duration, Kestrel and routing instruments keep reporting probe traffic and a dashboard does not go dark (ProbeTelemetryFilterProcessor.cs:16-18).
  • Walkthrough:
    • ProbeMarkerTagName = "mmca.probe" (:33) is the tag stamped on a refused probe request activity. Its doc states that it is never exported (:28-32), which is true by construction: the same pass that stamps it is the pass that refuses the span.
    • ShouldCollectRequest(HttpContext context) (:40) returns true (collect) for a null context or any non-probe path, delegating the path test to HealthEndpointPaths.IsProbePath(context.Request.Path.Value) (:42-45). Only when the path is a probe does it continue.
    • Before returning false it stamps the marker on Activity.Current, guarded by a pattern match on Kind: ActivityKind.Server (:49-52). The comment above it (:47-48) is the ordering fact that makes this legal: the instrumentation invokes the filter from its start callback, after ASP.NET Core has already started the request activity and made it current, so Activity.Current here is the request span.
    • Why stamp at all, when the span is about to be dropped? Because refusing the request means the instrumentation returns before it writes url.path, so the probe's descendants (the health check's SQL SELECT 1, the Redis PING) would have no evidence left on their ancestor to recognize. The marker is the evidence the processor matches on (:13-19).
    • ShouldCollectOutgoing(HttpRequestMessage request) (:62) is a single expression: collect unless the request has a URI whose path is a probe path (:63). A request with no RequestUri is collected, so the predicate never suppresses something it cannot classify.
    • PathOf(Uri uri) (:72) extracts that path. An absolute URI yields AbsolutePath directly (:74-77); a relative one (legal on a client with a BaseAddress) is trimmed at the first ? or # over a span (:79-81). The doc gives the reason for handling both shapes inline (:65-67): service discovery hands the client an absolute URI, but combining a relative one into a new Uri would allocate on every outbound call.
  • Why it's built this way: The split between the two predicates is not symmetry for its own sake, it follows the two ways probe traffic arrives. ShouldCollectRequest handles probes received, whose children are handled downstream by the processor. ShouldCollectOutgoing handles probes sent from a background timer, which are never descendants of any inbound request and so would never reach the processor at all: the gateway's DownstreamServiceHealthCheck calls to each backend's /alive, and YARP's active health checks (:20-25). Both filters are attached to the default-named instrumentation options (Extensions.cs:229-233), which is what makes them authoritative over the instrumentation the Azure Monitor distro adds as well, unlike the metric toggles that need a View. The type is internal static: it is a predicate pair, not an extension point, and the assembly exports the processor rather than the filter. ADR-041 is the policy this implements, cost knobs whose defaults never go dark, which here means traces filtered by default while metrics keep flowing.
  • Where it's used: Assigned to both instrumentation options inside ConfigureOpenTelemetry's WithTracing block, but only on the enabled branch: options.Filter = Telemetry.ProbeTelemetryFilter.ShouldCollectRequest and options.FilterHttpRequestMessage = Telemetry.ProbeTelemetryFilter.ShouldCollectOutgoing (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:229-233). The else branch registers the same two instrumentations with no filter (Extensions.cs:236-239), so turning the knob off costs nothing but the filtering. The knob itself is Telemetry:FilterProbeTelemetry (Extensions.cs:35), read by IsProbeTelemetryFilterEnabled (Extensions.cs:483-484). Covered by ProbeTelemetryFilterTests, which pins each behaviour: probe paths are not collected (ProbeTelemetryFilterTests.cs:57), normal paths are (:66), a null context is (:70), a probe request marks the current server activity (:75) and a normal one does not (:91), outbound probe calls are refused on absolute (:110) and relative (:117) URIs, a normal outbound call is collected (:126) and one without a URI is (:131). The same class also pins the knob's default-on parsing (:30, :38, :46).
  • Caveats / not-in-source: The marker is stamped on Activity.Current only, so if some future instrumentation invoked the predicate outside the request activity's scope the marker would be lost and the processor would fall back to its tag and display-name matching. What counts as a probe is entirely HealthEndpointPaths' definition: /alive, /health and anything under /health/ (HealthEndpointPaths.cs:29-33), so a host that maps a probe on a bespoke path is not filtered. The production-volume claims in the class doc (:6-12) are the recorded observation behind the knob and are not verifiable from source.

ProbeTelemetryFilterProcessor

MMCA.Common.Aspire · MMCA.Common.Aspire.Telemetry · MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Telemetry/ProbeTelemetryFilterProcessor.cs:20 · Level 10 · class (sealed)

  • What it is: The span processor that suppresses everything underneath a health-probe request: the database check's SQL SELECT 1, the Redis PING, the gateway's HttpClient call to a backend's /alive. ProbeTelemetryFilter refuses the probe request span itself, but its children are sampled independently and would still be exported without this pass (ProbeTelemetryFilterProcessor.cs:6-12).
  • Depends on: ProbeTelemetryFilter for the marker tag name (:68) and HealthEndpointPaths for the path test (:77-79). Externals: OpenTelemetry's BaseProcessor<Activity> and System.Diagnostics.Activity / ActivityKind / ActivityTraceFlags (:1-2). Registered by the service-defaults Extensions; it is the structural twin of OutboxPollFilterProcessor.
  • Concept introduced: none new. The suppression mechanism (clear Recorded so the batch export processors skip the span, keeping the parent chain intact) is taught at OutboxPollFilterProcessor; the probe-filtering rationale is taught at ProbeTelemetryFilter. [Rubric §31, Cost & FinOps]: the class doc records that probe descendants made up most of the AppDependencies volume in both production workspaces (:6-12), and this is the pass that removes them. [Rubric §13, Observability & Operability]: the suppression is scoped by span kind so a normal request never loses its subtree because one dependency happened to be a probe call (:73-79).
  • Walkthrough:
    • Two private const string fields, UrlPathTagName = "url.path" and HttpRouteTagName = "http.route" (:25-26). The comment above them (:22-24) states both the source of the values (set by the ASP.NET Core instrumentation on the server span at request start) and why they are literals: the package deliberately takes no dependency on the instrumentation's internal attribute class. This is the same commented literal-duplication trade OutboxPollFilterProcessor makes.
    • OnStart and OnEnd are both one-liners delegating to the same SuppressWhenUnderProbe(data) (:29, :40). The OnEnd remarks (:31-39) explain why the pass runs twice: a span's identifying tags do not all exist at start (a client span is started before its instrumentation has written any attribute), so an OnStart-only filter would depend on callback ordering it does not control. Clearing Recorded at end is what actually keeps the span out of the exporters; the OnStart pass additionally stops the instrumentation from collecting data it will never export.
    • SuppressWhenUnderProbe (:42) opens with a null check that returns rather than throws, under the comment "Never throw from a telemetry callback" (:44-48), matching the sibling processor's rule.
    • It then walks the in-process parent chain, for (var current = data; current is not null; current = current.Parent) (:52), because a probe's dependency spans can sit several levels below the request span: HttpClient handler, then connection, then DNS (:50-51).
    • On the first ancestor that is a probe it clears two things (:59-60): data.ActivityTraceFlags &= ~ActivityTraceFlags.Recorded makes the batch export processors skip the activity, and data.IsAllDataRequested = false stops instrumentation from enriching it in the first place (:56-58).
    • IsProbeRequest(Activity activity) (:66) tries the cheap answer first: the marker tag ProbeTelemetryFilter stamped, matched by presence rather than value (:68-71). Failing that it requires Kind == ActivityKind.Server and then tries three probe-path candidates in turn: the url.path tag, the http.route tag, and the route parsed out of the display name (:76-79). The server-kind restriction is the §13 guard named above (:73-75): an outgoing call to a probe endpoint is the gateway's own health check, and is filtered at the instrumentation instead.
    • RouteOfDisplayName(string displayName) (:89) takes the substring after the last space, or the whole name when there is none (:91-92). Its doc gives the reason this third candidate exists (:82-88): once the route is resolved the ASP.NET Core instrumentation renames the span to "{method} {route}", which is the only probe evidence left on a span whose tags an exporter-side enricher has already consumed.
  • Why it's built this way: Three independent recognitions (marker tag, path or route tag, display name) exist because the same probe span is identifiable by different evidence depending on which pass is looking and what has already run. Matching a whole subtree by walking parents rather than by trace id keeps the processor stateless: there is no per-trace dictionary to bound or evict, which is what lets it be a single sealed instance on the path of every span. Registering it only on the enabled branch, rather than making it check a flag per span, keeps the off state genuinely free. ADR-041 is the governing record: cost levers with defaults that fail safe, alongside head-based sampling and the metric kill switches.
  • Where it's used: Registered once, inside ConfigureOpenTelemetry's WithTracing block, as tracing.AddProcessor(new Telemetry.ProbeTelemetryFilterProcessor()) guarded by the same filterProbeTelemetry flag as the instrumentation filters (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:248-254), immediately after .AddProcessor(new Telemetry.OutboxPollFilterProcessor()) (Extensions.cs:246) and before the sampler and AddOpenTelemetryExporters(). The comment there restates the ordering contract (Extensions.cs:250-252): registration before the exporters is what makes the cleared Recorded flag the thing their batch processors see. Every host calling AddServiceDefaults() gets it unless it sets Telemetry:FilterProbeTelemetry=false. Covered by ProbeTelemetryFilterProcessorTests, which drives a real ActivityListener and pins each path: a child of a probe request is unrecorded (ProbeTelemetryFilterProcessorTests.cs:60), a child of a normal request stays recorded (:73), a grandchild is unrecorded (:86), the probe request itself is (:99), recognition works by marker tag (:109), by http.route alone (:126) and by display name alone (:142), a client span to a probe endpoint stays recorded (:154), an unrelated root stays recorded (:171), a span whose tags arrive after start is caught by the OnEnd pass (:183), and a null activity does not throw (:200).
  • Caveats / not-in-source: The walk is over Activity.Parent, the in-process chain only, so a span whose probe ancestor exists only as a propagated remote context is not matched. Suppression is all-or-nothing per host: the one knob turns both the filters and this processor off together, so there is no way to keep probe dependency spans while dropping the probe request spans. Recognition by display name is a heuristic on an instrumentation-chosen format, and source shows nothing that would fail if that format changed: a non-probe span whose display name happens to end in /health or /alive and whose kind is Server would also be suppressed.

⬅ Common UI Framework (MudBlazor components, theme, base pages)IndexADC Conference - Domain Model & Module Contracts ➡