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
DistributedApplicationTestingBuilderand polls the gateway's/healthuntil 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 generatedProjects.MMCA_ADC_AppHostmarker 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 throughWebApplicationFactory<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 aWaitForcycle is invisible todotnet buildand 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 stringfields name the resource to probe:GatewayResourceName = "gateway"andGatewayEndpointName = "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
TimeSpanbudgets 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, andPollInterval = 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 aCancellationTokenSourcefrom the startup budget (:44), builds the application model withDistributedApplicationTestingBuilder.CreateAsync<Projects.MMCA_ADC_AppHost>(startup.Token)(:46-47), thenBuildAsyncandStartAsyncunder the same token (:49-50).Projects.MMCA_ADC_AppHostis 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 singlestatus.Should().Be(HttpStatusCode.OK, ...)(:57-59) with abecausestring that restates the claim, thenapp.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 seedslast = HttpStatusCode.ServiceUnavailable(:77) so "the gateway never answered at all" is reported as a status rather than an exception, GETs the relative/healthURI (:83-84), returns immediately on 200 (:86-89), and swallowsHttpRequestException(:91-94) because a connection failure is the expected state while the gateway is still binding. TheTask.Delay(PollInterval, cancellationToken)sits in its owntry(:96-103) so budget expiry breaks the loop and returns the last observed status instead of surfacing anOperationCanceledException, which would report a cancellation where the useful signal is the last HTTP code seen.
- Two
- 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-smokerunscontinue-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 Testsworkflow (MMCA.ADC/.github/workflows/cross-service-tests.yml:1, scheduled at:27-31), in theapphost-smokejob (:198). It is not part of any localdotnet test --solutionrun. - 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 whoseWithSelectedBroker(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 twoWithBrokeroverloads on the Common AppHost-side Extensions (RabbitMQ) and itsAddServiceBusEmulatorBroker/ 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 twoWithBrokeroverloads 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 aFunc<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 anextension(IResourceBuilder<ProjectResource> service)block (:16). It null-guards the delegate withArgumentNullException.ThrowIfNull(attach)(:24) and returnsattach(service)(:25). That is all it does: the type carries no state and makes no decision itself. The decision lives in the AppHost, which declaresFunc<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) selectsbuilder.AddServiceBusEmulatorBroker(sqlServer)(:93-94), and anything else, including unset, selectsbuilder.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. SettingADC_BROKER=servicebusswaps 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-declaredsqlServerresource 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'sProgram.cschanges either way: the emulator's quota and TTL constraints are handled inside MMCA.Common'sAddBrokerMessaging, keyed off theUseDevelopmentEmulator=truemarker 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 isinternaland 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),AddBrokerMessagingshort-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.DataProtectionplus its Azure Blob Storage and Key Vault key-ring providers (Azure.Extensions.AspNetCore.DataProtection.Blobs/.Keys), andAzure.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 : IHostApplicationBuilderblock (:21-22), so it attaches to a web host and a worker host alike. AddCommonDataProtection()(:52) readsDataProtection: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:ApplicationNamefalling back tobuilder.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).DefaultAzureCredentialis 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:KeyVaultKeyUriis set (:81-82), it addsProtectKeysWithAzureKeyVault(...)(: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.
- The whole surface is one method inside a generic
- 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), andMMCA.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
AddCommonGatewayCorsextension method. - Depends on:
Microsoft.ExtensionsCORS / 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, unlikeMMCA.Common.API.AddCommonCors's allow-listed headers and methods, the production gateway policy allows any header and method while restricting origins toCors:AllowedOrigins. That origin restriction is load-bearing: the CORS specification forbids combiningAllowAnyOrigin()withAllowCredentials(), so to let cookies andAuthorizationheaders 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 anextension(IServiceCollection services)block (GatewayCorsExtensions.cs:18, method at:24). It null-guards the receiver and both arguments (:28-30), then registers a default policy insideservices.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 theCors:AllowedOriginsstring array, defaulting to[](:45-47), and buildsWithOrigins(origins).AllowAnyHeader().AllowAnyMethod().AllowCredentials()(:48-52). Because it is registered as the default policy, hosts pair it with a bareapp.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:89andMMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:77, each followed by a bareapp.UseCors()(ADCProgram.cs:137, StoreProgram.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:AllowedOriginsyields 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 byMapDefaultEndpoints()filter on. - Depends on: Nothing. Three
const stringfields. 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 aPredicateover 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/aliveand 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/readyand 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/healthbut 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
stringtags; aconstalso lets the values appear in collection expressions such astags: [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 theoptionalexclusion to/health/readyfor 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:/aliverequiresLive(:376-379) and/health/readyexcludes bothLiveandOptional(:392-395). The relational database checks are deliberately left untagged (:462,:467), which is what makes them readiness-fatal. GatewayHealthCheckExtensions tags every downstream probeReady. 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). Threeconst stringfields and a static method overstring.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 fromHealth + "/", so it cannot drift from the constant it extends.IsProbePath(path)(:29) returnsfalsefor null or empty, then matchesAliveorHealthexactly and anything underHealthPrefix(:30-33). The prefix arm is what makes/health/readymatch without being listed, and it also covers any sub-route a host maps later. Every comparison isOrdinalIgnoreCase, 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.
IsProbePathlives 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/livestyle 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 callsIsProbePathfor the inbound request path (Telemetry/ProbeTelemetryFilter.cs:42) and for an outgoing request URI (:63). ProbeTelemetryFilterProcessor calls it three ways on a finished activity, theurl.pathtag, thehttp.routetag, and the route parsed out of the display name (Telemetry/ProbeTelemetryFilterProcessor.cs:77-79). DownstreamServiceHealthCheck builds its relative probe URI fromAlive(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 throughIConfigurationexactly like any other setting, and overrides the sources added before it. - Depends on:
Azure.Extensions.AspNetCore.Configuration.Secrets(AzureKeyVaultConfigurationOptions,AddAzureKeyVault),Azure.Identity(DefaultAzureCredential), andMicrosoft.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,IConfigurationis 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 secretConnectionStrings--Defaultarrives as the keyConnectionStrings:Default, andJwt--SigningKeyasJwt: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 : IHostApplicationBuilderblock (:23-24), so it attaches to a web host and a worker host alike. AddCommonKeyVaultConfiguration()(:78) readsKeyVault: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 optionalKeyVault:ReloadIntervalMinutes(:96). When that key is present it must parse as a positive whole number under the invariant culture, or the method throwsInvalidOperationExceptionnaming the offending value (:102-103); a valid value becomesoptions.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).DefaultAzureCredentialis 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 tobuilder.Configuration, whoseConfigurationManagerbuilds 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.
- The whole surface is one method in a generic
- 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 askeyVaultUrlreferences 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 afterAddServiceDefaults()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:UrithrowsUriFormatExceptionfromnew 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 theIConnectionMultiplexerclient, andAddRedisOutputCaching()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) andMicrosoft.Extensions.Caching.StackExchangeRedis(AddStackExchangeRedisOutputCache), overIHostApplicationBuilder. 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 theAspNetCore.HealthChecks.Redischeck under the nameStackExchange.Rediswith no tags (:12-14). The readiness endpoint fromMapDefaultEndpoints()includes every check that is not taggedliveoroptional, so an untagged check silently gates readiness (:14-15). That check issuesCLUSTER INFOwhenever 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 withDisableHealthChecksand Common contributes its own PING-only check, namedredisand taggedOptional, fromAddInfrastructureHealthChecks(). - 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 : IHostApplicationBuilderblock (:36-37), the same shape as the other opt-in host extensions in this group. AddRedisCaching(connectionName = DefaultConnectionName)(:57) returns immediately whenGetConnectionString(connectionName)is null or blank (:59-62). Past that gate it callsbuilder.AddRedisDistributedCache(connectionName, settings => settings.DisableHealthChecks = true)(:64) andbuilder.AddRedisClient(connectionName, settings => settings.DisableHealthChecks = true)(:65). Registering the client alongside the cache is deliberate (doc comment:43-50): DistributedCacheService needs anIConnectionMultiplexerfor 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 registersAddStackExchangeRedisOutputCache(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 anEvictByTagAsyncreaches only the replica that served the mutation); it should be called beforeAddOutputCache(...), which registers its store withTryAddso 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 callsAddRedisOutputCaching()(:140). The three Store services call it inside an explicitifon 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 addsAddRedisOutputCaching()in the same branch (:104). Covered by RedisReadinessSafetyTests, which asserts that the wrapper registers no untagged Aspire check, that every Redis check is taggedOptional, 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.
AddRedisOutputCachingreads 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 localServiceDefaultsproject). The other class namedExtensionsin this chapter is the Common.Aspire.HostingExtensions, 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>(), andMapDefaultEndpoints(). 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-tunedSocketsHttpHandlerfor every outboundHttpClient. - 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 theMMCA.Common.Outboxtrace 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:FilterProbeTelemetryon by default plusTelemetry:TracesSampleRatioand the two metric-family switches opt-in, and theSocketsHttpHandlertuning atExtensions.cs:85-93, whose rationale comment at:74-84is the codebase's clearest FinOps decision:PooledConnectionLifetimepicks up ACA replica DNS rollover,PooledConnectionIdleTimeoutavoids 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 genericextension<TBuilder>(TBuilder builder) where TBuilder : IHostApplicationBuilderblock at:37-38, so it works for a web host or a worker): callsConfigureOpenTelemetry,AddDefaultHealthChecks,AddWarmupReadiness, thenAddServiceDiscovery(:48-51), thenConfigureHttpClientDefaults(:55), which affects everyHttpClientregistered downstream (typed clients, named clients, the YARP forwarder, and the gateway's own downstream probe clients). Inside it,AddStandardResilienceHandlerpulls 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, andMaxRetryAttempts = 1.http.AddServiceDiscovery()(:72) lets clients resolve logical service names, andConfigurePrimaryHttpMessageHandlerinstalls the tunedSocketsHttpHandler(:85-93) includingKeepAlivePingPolicy.WithActiveRequestsandEnableMultipleHttp2Connections = true(:91-92, so one multiplexed HTTP/2 connection cannot become a bottleneck). The comment at:57-64is 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 anIWarmupTask(:113), and the WarmupReadinessHealthCheck as the"warmup"check taggedHealthCheckTags.Ready(:115-116).AddWarmupTask<TTask>()(:390, in anextension(IServiceCollection services)block at:381) lets a consumer add service-specific warm-ups by registeringAddSingleton<IWarmupTask, TTask>()(:392).ConfigureOpenTelemetry()(Extensions.cs:128): logging withIncludeFormattedMessageandIncludeScopes(: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. ForTelemetry:DisableHttpClientMetrics(:148) the disabled branch does not merely skipAddHttpClientInstrumentation: it installs ametrics.AddView(...)that returnsMetricStreamConfiguration.Dropfor theSystem.Net.HttpandSystem.Net.NameResolutionmeters (:160-164). The comment above it (:150-159) records why: a deployed host also callsUseAzureMonitor(), and the Azure Monitor distro adds theSystem.Net.Httpmeter itself, sohttp.client.open_connectionskept flowing and stayed the single largest AppMetrics stream in both production workspaces despite the toggle being on. A View applies to the wholeMeterProviderregardless of which component added the meter, which is what makes the toggle authoritative rather than advisory. TheTelemetry:DisableRuntimeMetricsbranch (:175) applies the identical shape to theSystem.Runtimemeter (:180-183). When either knob is off, the correspondingAddHttpClientInstrumentation()(:168) orAddRuntimeInstrumentation()(: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 enablesScheduler:Enabled, the broker meter in a host that stays on the in-process bus). Tracing adds the application's own source plusMMCA.Common.Outbox(:209-210), then branches on the third cost knob:IsProbeTelemetryFilterEnabled(:224). When filtering is on (the default), ASP.NET Core andHttpClientinstrumentation are added with filters,options.Filter = Telemetry.ProbeTelemetryFilter.ShouldCollectRequestandoptions.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 theAppRequestsrows in both production workspaces, and their children (the health check'sSELECT 1, the Redis PING, the gateway's calls to each backend's/alive) most of theAppDependenciesrows; 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, andnew 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 theirOnEndclears theRecordedflag first (:241-245). WhenTryGetTraceSampleRatiosucceeds it installsnew 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 privateAddOpenTelemetryExporters(:265).TryGetTraceSampleRatio(configuration, out ratio)(Extensions.cs:448,internal static): reads the optionalTelemetry:TracesSampleRatioknob and returnstrueonly for a value that parses invariantly and falls in the open interval (0,1); absent, unparseable, or out-of-range input returnsfalseand leavesratioat1.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 returnstrue(drop that family) only when the value parses as booleantrue; absent, blank, or unparseable keeps the instrumentation.IsProbeTelemetryFilterEnabled(configuration)(Extensions.cs:483,internal static) reads the key held asFilterProbeTelemetryConfigKey = "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 explicitfalseturns 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 taggedHealthCheckTags.Live(:278-279), and nothing else. That single check is what/aliveanswers 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 privateAddDatabaseHealthChecks(:312), then adds Redis and RabbitMQ only when their connection strings are present. The Redis branch (:314-331) is the incident-hardened one: itTryAddSingletons a RedisPingHealthCheck built from the connection string (:323-324) and registers it as aHealthCheckRegistrationnamed"redis"tagged[HealthCheckTags.Optional](:326-330). The comment above it (:317-322) explains both halves: PING only, never an administrative command, because theAspNetCore.HealthChecks.Redischeck this replaced issuedCLUSTER INFOagainst 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 therabbitmqconnection string falling back tomessaging(:333-334), requires it to parse as an absolute URI (:335-336), and registers anAddRabbitMQcheck 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 whenrequireDatabaseis true and both are empty it throwsInvalidOperationExceptionwith a message naming both accepted shapes (:506-511). Otherwise it adds oneAddSqlServercheck per SQL source (:513-516) and oneAddSqlitecheck 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-levelConnectionStringsentry (:547-551) and then every namedDataSourceschild carrying the same key (:553-560), and finally deduplicates by connection string with an ordinalHashSet(: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'sWithSQLServerDataSourceproduces) contribute one check rather than one per logical name.MapDefaultEndpoints()(Extensions.cs:411, in anextension(WebApplication app)block at:398):/healthmaps every check (:413);/alivefilters toHealthCheckTags.Live(:417-420) so a downstream SQL outage cannot get the container restarted;/health/readymaps everything tagged neitherLivenorOptional(: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 whyoptionalis excluded.AddOpenTelemetryExporters()(Extensions.cs:361, private): enables OTLP whenOTEL_EXPORTER_OTLP_ENDPOINTis set (:363-369, the local Aspire dashboard sets it) and Azure Monitor whenAPPLICATIONINSIGHTS_CONNECTION_STRINGis 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
ServiceDefaultsproject, 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 scopedCA1708suppression (:25-28) documenting a known analyzer false positive: with two or moreextension(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'sProgram.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()followsBuild()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:
requireDatabasedefaults tofalse, so a service host that forgets to passtrueregisters 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 setsTelemetry: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
/aliveprobe 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 KestrelHttp2-only, so h2c prior knowledge is the only thing it answers, and a stockHttpClientsends HTTP/1.1, which it refuses. Sending HTTP/2 unconditionally has the mirror-image failure: an HTTP/1.1-only endpoint, or a mixedHttp1AndHttp2cleartext endpoint where Kestrel disables h2 because there is no ALPN to disambiguate, answersHTTP_1_1_REQUIREDforever. 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 withHttpVersionPolicy.RequestVersionExact, so the request never negotiates down (:20-24).Http11(:32) always sends HTTP/1.1 withRequestVersionOrLower, which is exactly stockHttpClientbehavior (:27-31).- That
Autois the zero value is load-bearing beyond defaulting: the latch field in DownstreamServiceHealthCheck stores the underlyingintand reads zero as "not settled yet" (DownstreamServiceHealthCheck.cs:48-55).
- Why it's built this way: An enum rather than a
boolbecause there are genuinely three states (discover, pin high, pin low), and the previous shape (a per-downstreamProbeOverHttp2boolean 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 singleAddGatewayDownstreamHealthCheckscall 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 onAuto. Covered by GatewayDownstreamHealthChecksTests, whoseOptions_DefaultToAutoNegotiationand 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) andStringComparer(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].IServiceCollectionis 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 commentGatewayHealthCheckExtensions.cs:219-224). The idiom the framework reaches for is to park a small mutable object in the collection itself as anImplementationInstanceand 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-insensitiveHashSet<string>: a gateway fronts a handful of services, and the comparer-carryingHashSetconstructor is one of the initializer shapes the IDE0028 analyzer misreports here.TryClaim(serviceName)(:240) returnsfalsewhen the name is already present, compared withStringComparer.OrdinalIgnoreCase(:242-245), otherwise appends it and returnstrue(: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 whoseServiceTypeis this type and whoseImplementationInstanceis a registry (:259-266), returning it when found; otherwise it constructs one, registers it withAddSingleton(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 anImplementationInstancerather 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 inMMCA.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 xPermitLimitper 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 inMMCA.Common.API'sRateLimitingSettings.Distributedis the tool. The second remark (:22-27) is the operational trap: partitioning is byConnection.RemoteIpAddress, soUseForwardedHeadersmust run beforeUseGatewayRateLimitingor 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), astatic readonly stringused 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 anIReadOnlyList<string>, empty by default, matched case-insensitively on whole path segments. The doc comment (:75-81) records that/health,/aliveand/.well-knownare 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 inappsettings.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-inappsettingsfile, 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;StringLengthtreats null as valid, which is exactly the intended "off" state.
- Why it's built this way:
sealedwithinit-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 throughValidateDataAnnotations().ValidateOnStart()and the object overload through an explicitValidator.ValidateObjectcall, 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 byAddGatewayRateLimiting(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 gatewayappsettings.jsonbeside 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 namedsynthetic-traffic-secretmapped ontoGatewayRateLimiting__SyntheticTrafficSecretand 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 asX-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
GlobalConcurrencyLimitis sensible relative toPermitLimit, 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:
IServiceCollectionandStringComparer(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 twoDistributedApplication.CreateBuilderinstances 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-HashSetnote the gateway registry carries (:155-159).TryClaim(key)(:168) compares case-insensitively and returnsfalseon a repeat (:170-173), otherwise appends and returnstrue(:175-176).GetOrAdd(services)(:185) scans the descriptors for a matchingImplementationInstance(:187-194) and otherwise registers a fresh one as a singleton (:196-198). The keys it holds are built byCheckKey(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:
WithH2cHealthCheckclaims its key before registering anything (H2cHealthCheckExtensions.cs:121-124). Both behaviours it buys are pinned by H2cHealthCheckExtensionsTests:WithH2cHealthCheck_IsIdempotent,WithH2cHealthCheck_KeysTheCheckByEndpointSoOneResourceCanGateTwo, andWithH2cHealthCheck_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
PINGand nothing else, and reports the round-trip latency as its healthy description. - Depends on:
StackExchange.Redis(IConnectionMultiplexer,ConnectionMultiplexer),IHealthCheck/HealthCheckResult(ASP.NET Core diagnostics), andIServiceProvider. It pairs with RedisCachingExtensions'AddRedisCaching()and is registered by the Common.Aspire Extensions under the HealthCheckTagsOptionaltag. - 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. TheAspNetCore.HealthChecks.Redischeck this replaced branches on the detected server type and issuesCLUSTER INFOagainst 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 threwRedisCommandExceptionon 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.PINGanswers 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, aSemaphoreSlim(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 throughAddRedisCaching()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), issuesmultiplexer.GetDatabase().PingAsync()(:52), and returnsHealthywith the latency formatted invariantly (:54-58). - Two catch clauses in a deliberate order.
OperationCanceledExceptionwhen 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/healthpayload 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.
- Four fields (
- Why it's built this way:
internal sealedframework 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.IAsyncDisposablerather than a fire-and-forget connection, so the fallback multiplexer is built once and released with the container.PINGreturns aTimeSpan, which is why the healthy description can carry latency at no extra cost. - Where it's used: Registered inside
AddInfrastructureHealthChecksonly when aredisconnection string is present (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:314-331):TryAddSingletonbuilds it once (:282-283) and theHealthCheckRegistrationnamed"redis"resolves that singleton and tags itHealthCheckTags.Optional(:285-289), so a cache outage degrades/healthwithout 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 = trueon both the distributed cache and the client) precisely so this one is the only Redis check in the pipeline. Covered by RedisPingHealthCheckTests, whose cases includeCheckHealthAsync_UsesPingOnly_AndNeverAnAdministrativeCommandandCheckHealthAsync_WhenTheServerRefusesACommand_ReportsUnhealthyWithoutRethrowing, and by RedisReadinessSafetyTests for the tagging. - Caveats / not-in-source:
PINGproves 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 theWithBrokeroverload 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].ConnectionStringExpressionends inUseDevelopmentEmulator=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 markerMMCA.Common.Infrastructuredetects 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) andAdminTargetPort = 5300(:44). - The constructor (
:48-53) callsbase(name)and creates the twoEndpointReferences against itself. The resource name doubles as the container's network alias (:47). AmqpEndpoint(:56) andAdminEndpoint(:59) expose those references.ConnectionStringExpression(:66-68) buildsEndpoint=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 (httptoday: the management plane is cleartext by design and reachable only from the local development stack). A consumer hands this toMessageBus: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. AWaitForon 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.
- Four constants pin the wire contract:
- Why it's built this way: A dedicated resource type rather than a bare
AddContainerbecause the two connection expressions are the reusable part, andIResourceWithConnectionStringis what letsWithReferenceinject the AMQP string automatically. Both endpoint names and both container ports arepublic constso 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 theWithBroker(IResourceBuilder<ServiceBusEmulatorResource>)overload (Extensions.cs:280-292). One consumer wires it today: ADC's AppHost picks the emulator over RabbitMQ whenADC_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
IHealthCheckthat GETs a single downstream service's/aliveendpoint through a service-discovery-resolvedHttpClient, 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), andHttpRequestError/HttpVersionPolicy/Interlocked(BCL). The probed path comes from HealthEndpointPaths. Registered, named and budgeted by GatewayHealthCheckExtensions; itsReadytag 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). ProbePathis astatic readonly Uribuilt fromHealthEndpointPaths.Alive(:46), the shared/aliveconstant (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 isAuto) reads as "not settled yet". It is written withInterlocked.CompareExchangerather 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:Autogoes toNegotiateAsync(:68-71), a pinned mode goes straight toSendProbeAsync(:73).- The outer catch (
:75-87) is narrow on purpose:HttpRequestException,TaskCanceledException,TimeoutExceptionandInvalidOperationException(unreachable, DNS-unresolvable, or slower than the probe budget) are reported ascontext.Registration.FailureStatuswith the exception attached, while thewhen (!cancellationToken.IsCancellationRequested)guard keeps host shutdown from being reported as a dependency fault. Reading the failure status from the registration rather than returningUnhealthydirectly 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 withVolatile.Readand short-circuits to the settled version when there is one (:103-107). Otherwise it probes HTTP/2 and, on any HTTP response at all, latchesHttp2(: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 toHttpRequestException 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.Version11withRequestVersionOrLowerfor HTTP/1.1, otherwiseVersion20withRequestVersionExact(:152-158). A success status returnsHealthywith 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: onlyHttpRequestError.VersionNegotiationErrorandHttpProtocolErrorjustify 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 singleInterlocked.CompareExchangeagainstAuto: first writer wins, a latched value is never replaced.
- 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 (
- 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 theAspNetCore.HealthChecks.Urispackage, 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 namedExtensionsin this chapter is the framework's Common.AspireExtensions(the canonical service-defaults bootstrap, service-side). Neither app ships a localServiceDefaultsproject; 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 fromMMCA.Common.Aspire(the service-defaults assembly every running service consumes) so that running services do not pull in the heavyAspire.Hostingtooling package (doc commentExtensions.cs:13-17). The rule bites in a visible place: rather than referencingGatewayRateLimitingSettings.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 scopedCA1708suppression (:19-22) documenting the known analyzer false positive: with two or moreextension(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) andE2eRegistrationsPerIpPerHour = 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 addsE2eLiftTriggerVariable = "E2E_LIFT_REGISTRATION_THROTTLE"(:42, the single trigger both lifts read),E2eGatewayPermitLimit = 100000(:48),E2eGatewayGlobalConcurrencyLimit = 10000(:54) andE2eAuthTightPermitLimit = 100000(:60), plus the three configuration keys they compose from:GatewayRateLimitingSection(:70),MmcaGatewaySection = "MmcaGateway"(:77) andAuthTightPolicyName = "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 pinsDefaultServiceBusEmulatorResourceName = "servicebus"(:89),ServiceBusEmulatorImageRegistry = "mcr.microsoft.com"(:94),ServiceBusEmulatorImage(:99) andServiceBusEmulatorImageTag = "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 fixesDefaultMailDevResourceName = "maildev"(:112),MailDevHttpPort = 1080(:118) andMailDevSmtpPort = 1025(:124). AddMailDev(name)(:141, in anextension(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'sSmtp:Portsetting 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 chainsWithImagebeforeWithImageRegistry, 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 astcpand the admin endpoint as HTTP (:216-222), setsACCEPT_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 asSQL_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 genericextension<TResource>(IResourceBuilder<TResource> service) where TResource : IResourceWithEnvironment, IResourceWithWaitSupportblock 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__ConnectionStringfrom the resource's emulator-form connection string andMessageBus__EmulatorAdminEndpointfrom its management-plane expression (:286-291). The doc comment (:271-276) restates the mode-switch rule: the connection string carriesUseDevelopmentEmulator=true, which is the markerAddBrokerMessagingkeys 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 setsAuthentication__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 defaultJwtBearerbackchannelHttpClientsends 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 anextension(IResourceBuilder<ProjectResource> identity)block at:340): a CI/E2E-only helper that readsE2E_JWT_PRIVATE_KEY_PEM/E2E_JWT_PUBLIC_KEY_PEMfrom the AppHost's own environment (:357-358), returns the builder unchanged when either is missing (:359-362), and otherwise maps them ontoJwt__RsaPrivateKeyPem,Jwt__RsaPublicKeyPemandJwks__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): computesliftas the argument OR-ed with an ordinal-ignore-case comparison ofE2eLiftTriggerVariableagainst"true"(:396-400), and when lifted setsLoginProtection__MaxRegistrationsPerIpPerHourtoE2eRegistrationsPerIpPerHourformatted 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 anextension(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-routeMmcaGateway__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.WithEnvironmentsettingDataSources__{logicalName}__SQLServerConnectionString(:490-493), which the double-underscore convention flattens toDataSources:{logicalName}:SQLServerConnectionStringfor 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 itsDefaultsource, so the framework's own tables and the readiness health check both land on it and the logical name collapses ontoDefault, 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 toCosmosDbContext.UseCosmos(...), andCosmosDatabaseName, sinceUseCosmostakes 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 ofWithSQLServerDataSource(:506-507).WithSqliteDataSource(logicalName, filePath)(:537): the SQLite sibling. SQLite has no Aspire container resource (it is an in-process file), so this only injectsDataSources__{logicalName}__SqliteConnectionStringbuilt asData Source=<path>(:544-547). Note the different signature, afilePathstring instead of a database resource.
- Constants.
- 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 optionalgateway?lets a monolith deployment still use JWKS by pointing straight at Identity. The threeWith*DataSourcehelpers share one naming shape so an engine move is a one-line AppHost change, and makinglogicalNamean explicit parameter keeps each service's database identity discoverable by reading the AppHost. The threeWithE2e*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.csof 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)whenADC_BROKER=servicebus, otherwiseAddMessageBroker()with a persistent lifetime), adds MailDev once (:109), callsWithSQLServerDataSourceper 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 (:428on Identity,:435on the gateway, each withalsoLiftWhen: forceWasm). Store does the same for its three services: broker atMMCA.Store/Source/Hosting/MMCA.Store.AppHost/Program.cs:48, MailDev at:55,WithSQLServerDataSourceat:128,:150,:191,WithBroker(rabbit)at:130,:152,:194,WithE2eRsaKeys()at:168,WithJwksDiscoveryat: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:29andMMCA.ECommerce/Source/Hosting/MMCA.ECommerce.AppHost/Program.cs:20-21(two logical sources on one host). TheWithCosmosDataSourceandWithSqliteDataSourcehelpers 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, includingWithE2eGatewayRateLimitLift_EmitsKeysDerivedFromTheCommonOwnedSectionNames(the cross-assertion that keeps the mirrored constants honest) andWithE2eRegistrationThrottleLift_SharesTheGatewayLiftsTrigger; the emulator path by ServiceBusEmulatorBrokerTests. - Caveats / not-in-source: For all three
With*DataSourcehelpers thelogicalNamemust 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 theDefaultsource rather than failing at startup. Several consumer AppHost comments still describeWithSQLServerDataSourceas injecting aConnectionStrings__variable as well (for exampleMMCA.Helpdesk/Source/Hosting/MMCA.Helpdesk.AppHost/Program.cs:10); the current implementation sets only theDataSources__key and relies on the resolver's collapse-onto-Defaultbehaviour.
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 onProbeVersion(:45-57) are the same negotiation argument taught at DownstreamProbeVersion, stated from the caller's side. - Walkthrough: One member.
ProbeVersion(:59) is aget/setproperty defaulting toDownstreamProbeVersion.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
setrather thaninit, because the consumption pattern is the standardAction<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 theparams-only overload. Covered by GatewayDownstreamHealthChecksTests (Options_DefaultToAutoNegotiation,AddGatewayDownstreamHealthChecks_WithOptions_RegistersTheSameChecks, andProbeVersion_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
AddGatewayRateLimitingand inserted into the pipeline byUseGatewayRateLimiting. - Depends on: GatewayRateLimitingSettings (its configuration), plus
System.Threading.RateLimiting(PartitionedRateLimiter,RateLimitPartition,FixedWindowRateLimiterOptions,ConcurrencyLimiterOptions),CryptographicOperations(BCL cryptography),IServiceCollection/IApplicationBuilder, andValidator(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:AddCommonRateLimitinginMMCA.Common.APIpartitions 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].IsSyntheticTrafficis 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 throughCryptographicOperations.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 configuredBypassPathPrefixesand matches withPathString.StartsWithSegments(..., OrdinalIgnoreCase)(:72-75), skipping blank entries. Segment matching is the detail:/healthzis not matched by/health. It isinternalrather thanprivateso the matcher is unit-testable (:66).IsSyntheticTraffic(httpContext, settings)(:94) returnsfalseimmediately when no secret is configured (:99-102), then requires exactly one header value under the configured name (:104-108), and finishes withCryptographicOperations.FixedTimeEqualsover the UTF-8 bytes of both sides (:110-113).ClientIpPartition(httpContext, settings)(:124) returnsRateLimitPartition.GetNoLimiter(BypassPartitionKey)when either exemption applies (:131-134); returnsGetNoLimiter(UnknownIpPartitionKey)whenConnection.RemoteIpAddressis 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-processTestServerand the integration tier to a standstill. Otherwise it returns a fixed-window limiter keyed on the IP string, withPermitLimitandWindowfrom settings andQueueLimit = 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 withGlobalConcurrencyLimitpermits and, again,QueueLimit = 0(:169-176).AddGatewayRateLimiting(IConfiguration configuration)(:188, in anextension(IServiceCollection services)block at:179) binds the"GatewayRateLimiting"section throughAddOptions().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 runsValidator.ValidateObject(settings, ..., validateAllProperties: true)at registration (:218), with the reasoning inline (:215-217): the limiter closes over this settings instance rather than resolvingIOptionsper 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 callsAddRateLimiter(:220), setsRejectionStatusCodeto 429 (:222), and assignsoptions.GlobalLimiter = PartitionedRateLimiter.CreateChained(...)over the two partition functions (:227-231). Assignment rather thanAddPolicyis deliberate (comment:224-226): calling the method twice replaces the limiter instead of throwing on a duplicate policy name.UseGatewayRateLimiting()(:244, in anextension(IApplicationBuilder app)block at:236) null-guards and returnsapp.UseRateLimiter()(:246-247). Its doc comment (:238-242) carries the ordering contract: afterUseForwardedHeaders()so the client IP is the caller's rather than the ingress's, and before the proxy is mapped.
- Three partition-key constants (
- Why it's built this way: Pure static partition functions with
internalvisibility 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 scopedCA1708suppression 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 (ADCProgram.cs:144, StoreProgram.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
IHealthCheckthat 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), andSocketsHttpHandler/HttpClient/HttpVersionPolicy(BCL). Registered by H2cHealthCheckExtensions, whoseProbeTimeoutit reuses. It is the AppHost-tier analogue of DownstreamServiceHealthCheck, sharing the same version pair. - Concept introduced, gating a
WaitForon 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 defaultHttpClient, so the request goes out HTTP/1.1 and an Http2-only cleartext endpoint refuses it with GOAWAYHTTP_1_1_REQUIRED(doc comment:11-16). The check therefore never turns healthy, the resource cannot be health-gated at all, and everyWaitForedge 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 everyWaitForedge 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,ConnectTimeoutdoes not cover this case because the connect itself succeeds. - Walkthrough:
ProbeHandler(:46-52) is astatic readonly SocketsHttpHandlerwithUseProxy = 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:KeepAlivePingDelayandKeepAlivePingTimeoutof one second each (:49-50) withHttpKeepAlivePingPolicy.Always(:51).ProbeClient(:59-60) is one process-lifetimeHttpClientover that handler, constructed withdisposeHandler: falseandTimeout = 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): aFunc<string>URL resolver, the path, and a send delegate. The production constructor (:71-74) takes anEndpointReferenceand passes() => endpoint.UrlplusProbeClient.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 anHttpMessageHandler: 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 owntry(:106-120). AnInvalidOperationExceptionthere 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) withVersion = HttpVersion.Version20andVersionPolicy = 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 returnsHealthywith 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 outlivesProbeTimeoutused 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 sealedwith 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
WithH2cHealthCheckinside itsHealthCheckRegistrationfactory (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, soWaitForedges 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) andHealthCheckRegistration(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 onDefaultProbePath(: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/alivefor 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. InjectingHealthProbe__Portlocally flips KestrelEndpointExtensions out of endpoint-defaults mode and into explicit-listener mode, whoseListencalls then override theASPNETCORE_URLSbinding 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) andDefaultEndpointName = "http"(:59, the cleartext endpoint is the h2c one, since anhttpsendpoint 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 anextension(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, setsfailureStatus: HealthStatus.Unhealthy, passestags: null(AppHost-side checks are not on a service's/healthendpoints, so the HealthCheckTags vocabulary does not apply), and appliesProbeTimeout. - The final
return builder.WithHealthCheck(key)(:137) is the half that is easy to miss, and the comment says so (:135-136):WithHealthCheckonly associates the named check with the resource, while the registration above is what supplies it, and both halves are required for aWaitForedge 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 servesHttp1AndHttp2, Aspire'sWithHttpHealthCheckis fine and this one is unnecessary (:84-88). The class carries the same scopedCA1708suppression 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:108and its consequence for the gateway'sWaitForedges 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
AddGatewayDownstreamHealthChecksthat register adownstream-{name}readiness check and a matching namedHttpClientfor 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
Readytag), plusIServiceCollection,IHttpClientFactoryandHealthCheckRegistration(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'sBaseAddressis the literalhttp://{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). TaggingReadyputs the check on/healthand/health/readybut 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 isUnhealthy, notDegraded, because/health/readytreats 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 behindOptionalinstead. ADR-088 is the record. - Walkthrough:
- Shared vocabulary:
CheckNamePrefix = "downstream-"(:73),ClientNamePrefix = "gateway-downstream-"(:76), and the helpersCheckName(serviceName)(:89) andClientName(serviceName)(:94) that apply them. Allinternal, 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/readypoll, 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) andAddGatewayDownstreamHealthChecks(Action<GatewayDownstreamHealthCheckOptions>? configure, params string[] serviceNames)(:148) both delegate to the privateRegister(: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 copiesoptions.ProbeVersioninto 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
BaseAddressishttp://{name}and whoseTimeoutisProbeTimeout(: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 underAutoa single poll may send both versions (:197-200). - It then adds a
HealthCheckRegistration(:203-212) whose factory resolvesIHttpClientFactoryand constructs a DownstreamServiceHealthCheck with the captured probe version (:205-209), withfailureStatus: HealthStatus.Unhealthy(:210),tags: [HealthCheckTags.Ready](:211) andtimeout: ProbeTimeout(:212).
- Shared vocabulary:
- Why it's built this way: A
paramsarray, 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 scopedCA1708suppression (:66-69) for theextension(T)analyzer false positive. And the whole kit lives inMMCA.Common.Aspirerather thanMMCA.Common.APIbecause 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:78with four names (identity,conference,engagement,notification) andMMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:112with three (catalog,identity,sales). Both are single calls, and bothProgram.csfiles 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
ReverseProxycluster destinations inappsettings.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,HttpContextandSystem.Diagnostics.Activityonly. Registered through GatewayCorrelationExtensions. It is the edge twin of CorrelationIdMiddleware inMMCA.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), apublic const. Its doc comment (:29-33) flags that this is deliberately the same literal asCorrelationIdMiddleware.HeaderNameinMMCA.Common.APIrather 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 fromActivity.Current?.TraceIdwithcontext.TraceIdentifieras the fallback (:53, the same precedence the service middleware uses, so the correlation id and the distributed trace line up), and then written back ontocontext.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 plusInvokeAsync) middleware, and context-free by constraint, not by accident. Its only constructor dependency is theRequestDelegate(:27). The service-tier version writes the id onto a scopedICorrelationContextthat 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:129andMMCA.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 callingUseMiddleware<T>()directly is the documented ordering rule, which is the part a host gets wrong. - Walkthrough: One
extension(IApplicationBuilder app)block (:75) containingUseGatewayCorrelation()(:82), which null-guards the receiver (:84) and returnsapp.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 scopedCA1708suppression as its siblings (:69-72) for theextension(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:129andMMCA.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 examplescript-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 inHttpContext.Itemsbefore 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), theHttpContext.Itemskey 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), thenreturn context.Items.TryGetValue(ItemKey, out var value) ? value as string : null(:123). Theas stringrather than a cast means a foreign object stored under the same key degrades tonullinstead of throwing on a response path.- A
nullreturn 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 injectsIHttpContextAccessorand 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.Itemsrather 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 oneGetreturns (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 thatGeton a context the middleware never touched returnsnull(:183-184). - Caveats / not-in-source: No host in either app emits a
{nonce}policy today. The shared BlazorCspPolicyProvider that both Blazor hosts register buildsstyle-src 'self' 'unsafe-inline'(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Security/BlazorCspPolicyProvider.cs:81) rather than a nonce source list, and no.razorfile in the workspace referencesCspNonce. 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).Valueis the full directive string, for example the framework baselinedefault-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'.Enforcedecides the header name:truemakes the middleware writeContent-Security-Policy(:193),falsemakes it writeContent-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 recordfor immutability and structural equality. CarryingEnforceon 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 bySecurityHeadersMiddleware.InvokeAsync(:177-199). - Caveats / not-in-source: A
nullreturn 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'sstoppingToken, 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.
AddWarmupReadinessregisters the built-in OpenIdConnectMetadataWarmupTask (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:113), and a host adds its own withAddWarmupTask<TTask>()(Extensions.cs:390, whose body is the singleservices.AddSingleton<IWarmupTask, TTask>()at:351). The wholeIEnumerable<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.WhenAllschedules 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.BuildListenerPlanand consumed by itsConfigureEndpointsWithHealthProbe. - 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.ConfigureKestrelis a callback that only runs when the host boots, andListenAnyIPbinds 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 returningIReadOnlyList<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 insideKestrelEndpointExtensions, with per-parameter doc comments at:129-131.Portis the port to listen on;Protocolsis the protocol set that port accepts (Http1,Http2, orHttp1AndHttp2). - Why it's built this way: A record, so structural equality makes a test assertion read as a plain comparison of expected listeners.
internaland 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 callingListenAnyIPinline is the whole reason the surprising parts of the wiring (re-declaring the cleartext endpoint, forcing the probe listener toHttp1) are verifiable at all. - Where it's used: Returned in declaration order by
BuildListenerPlan(KestrelEndpointExtensions.cs:113-127) and looped over insideConfigureKestrelat:94-97, where each spec becomes onekestrel.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 setX-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 aconfiguredelegate. - 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);PermissionsPolicydenying geolocation, microphone, camera, and payment (:31);EnableHsts = true(:34) withHstsValue = "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-srcandstyle-srcship 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 tonullor 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 itfalseand the middleware emits the same policy report-only.
- Why it's built this way: Sealed and mutable (
get; set;, notinit-only) so theconfiguredelegate andIOptionsbinding 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 omittedscript-srcandstyle-srcto 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 overrideEnableHststofalsethrough theconfiguredelegate (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-Optionshas no setting; the middleware hard-codesnosniff(: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), andMicrosoft.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 wholeILoggerFactoryand therefore silently bypasses every other provider, including the OpenTelemetry-to-Azure-Monitor one wired byAddServiceDefaults(). 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 anextension(WebApplicationBuilder builder)block at:29) null-guards the builder (:52), assignsLog.Logger = CreateLoggerConfiguration(builder.Environment, logFilePath, configure).CreateLogger()(:54), registers it withbuilder.Logging.AddSerilog(Log.Logger, dispose: true)(:55), and returns the builder (:57). Two things happen at once and both matter: the globalLog.Loggeris 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: truehands the provider ownership of the logger so buffered events flush on shutdown. The doc comment (:31-36) states the ordering rule: call it beforeAddServiceDefaults(), 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 globalLog.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 ownLog.Logger, which is why disposing it does not silence the host.ResolveMinimumLevel(environment)(:76-77, internal static):Debugin Development,Informationeverywhere 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, includingArgumentException.ThrowIfNullOrWhiteSpace(logFilePath)(:103-104), then buildsMinimumLevel.Is(ResolveMinimumLevel(environment))with two noise overrides,Microsoft.EntityFrameworkCoreandMicrosoft.AspNetCoreboth floored atWarning(:107-109), andWriteTo.Console(formatProvider: CultureInfo.InvariantCulture)(:110). WhenShouldWriteFileSinksays so it addsWriteTo.File(logFilePath, rollingInterval: RollingInterval.Day, ...)(:112-118). Only then does it invoke the optionalconfigurehook (: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.
- The one public entry point.
- 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 scopedCA1708suppression 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 oneSerilogLoggerProvider(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 forUseSerilog(). - Where it's used: All seven ADC and Store service hosts, each pairing
AddCommonSerilogwithCreateBootstrapLoggerFactory()in the same file: ADC Conference (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:100and:307), Engagement (:82,:208), Identity (:96,:247), Notification (:85,:186); Store Catalog (MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:67and: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 doesMMCA.ADC.UI.Web: those three take the plain OpenTelemetry logging thatAddServiceDefaultsgives them.MMCA.Store.UI.Webis 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 toWebApplicationBuilder, not to the genericIHostApplicationBuilderthatAddServiceDefaultsuses, 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
trueexactly once when every IWarmupTask has finished, failed, or timed out, gating the/health/readyendpoint. - Depends on: Nothing first-party. Read by WarmupReadinessHealthCheck, set by WarmupHostedService. Uses
VolatileandInterlocked(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 untilIsReadyistrue, 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/alivestays narrow so a dependency outage never restarts the container (see HealthCheckTags). - Walkthrough:
private int _isReady(WarmupReadinessGate.cs:12), 0 or 1. Using anintwithVolatile.ReadandInterlocked.Exchangerather than aboolplus 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()isinternal(:18), so no external caller can prematurely open the gate; onlyWarmupHostedService, 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 thefinallyofWarmupHostedService.ExecuteAsync(Warmup/WarmupHostedService.cs:58); read byWarmupReadinessHealthCheck.CheckHealthAsync(Warmup/WarmupReadinessHealthCheck.cs:14), which is registered as the"warmup"check taggedHealthCheckTags.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 dynamicconnect-src, for example one pinned at runtime to its API origin, registers a customICspPolicyProviderbefore callingAddCommonSecurityHeaders, because that method registers the default only viaTryAddSingleton(: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, ornullto emit none. Taking theHttpContextmakes 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-srcto 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 inMMCA.Common.UI.Web, which both apps' Blazor hosts register throughAddCommonBlazorCsp(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:39) ahead ofAddCommonSecurityHeaders(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'shttpGethealth 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.AspireExtensionsMapDefaultEndpoints(). - 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 fromMMCA.Common.Grpcspeak h2c directly. Second, Azure Container AppshttpGetprobes speak HTTP/1.1, which an Http2-only endpoint rejects with GOAWAYHTTP_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 separateHttp1-only listener on a port never published through ingress gives the platform a probe target, and becauseMapDefaultEndpoints()maps/health,/alive, and/health/readyon 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 asHealthProbe__Portand 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 anextension(WebApplicationBuilder builder)block at:39): null-guards the builder (:82), computes the listener plan up front (:84-88), then insidebuilder.WebHost.ConfigureKestrel(:90) setskestrel.ConfigureEndpointDefaults(endpoint => endpoint.Protocols = defaultProtocols)(:92) and declares each planned listener withkestrel.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 passesHttpProtocols.Http2and keepsredeclareCleartextEndpointat its default, because an explicitListencall overrides the container'sASPNETCORE_HTTP_PORTSdefault 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 anHttp1AndHttp2endpoint for the WebSocket upgrade handshake plus an Http2-only gRPC endpoint, both fromappsettings.json) passesHttpProtocols.Http1AndHttp2andredeclareCleartextEndpoint: 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 probePortreturns 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 onredeclareCleartextEndpoint(:124-126). The probe listener is alwaysHttpProtocols.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 makesGetValue<int?>throwInvalidOperationExceptionat 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.
- Two public constants.
- 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, passingHttpProtocols.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__Portsimply 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 fromappsettings.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, andILogger<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-configurationlazily 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 theHttpClientpool. The honest caveat is stated in the type's own remarks (:14-20): the middleware'sConfigurationManagercaches 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, andILogger<OpenIdConnectMetadataWarmupTask>(:21-24).Name => "OpenIdConnectMetadata"(:26).ExecuteAsync(:28) readsAuthentication: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 aGET(: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'sAddWarmupReadinessrather than by consumers,partialfor the[LoggerMessage]source generator, sealed by default policy. ReusingIHttpClientFactoryis the load-bearing detail: the warm-up exercises exactly the connection pool (and the tunedSocketsHttpHandlerfrom 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
IWarmupTasksingleton byAddWarmupReadiness(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:113), so every host that callsAddServiceDefaultsgets 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
ConfigureHttpClientDefaultsapplies 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:
IServerplusIServerAddressesFeature(the actual bound address),IConfiguration(theASPNETCORE_URLSfallback),IHostEnvironment(the Testing short-circuit),IHostApplicationLifetime(theApplicationStartedwait), andILogger(:28-33). It creates its ownSocketsHttpHandlerandHttpClient(:111-117) rather than taking anIHttpClientFactory. - 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 concreteExecuteAsync(:93) calling five members a subclass may or must supply (Name:49andWarmupPaths:59abstract;RequestVersion:70,RequestVersionPolicy:77, andRequireSuccessStatusCode:90virtual 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; andprivate const string TestingEnvironmentName = "Testing"(:46), whose doc comment explains the short-circuit below: an integration test boots the host throughWebApplicationFactory, whose in-memoryTestServernever opens a socket, so a self-HTTP request there could only ever fail (:41-45). - The extension points.
Name(:49) andWarmupPaths(:59) are abstract. TheWarmupPathsdoc 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.RequestVersiondefaults toHttpVersion.Version20(:70) andRequestVersionPolicytoHttpVersionPolicy.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 staysHttp1AndHttp2and overrides both members.RequireSuccessStatusCodedefaults totrue(:90), suiting an anonymous read whose response body is what populates the output cache; a protected endpoint overrides it tofalse, 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). ThenWaitForServerStartedAsync(:102), defined at:191-199: it registers a callback onlifetime.ApplicationStartedthat completes aTaskCompletionSource(created withTaskCreationOptions.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
UriBuilderoverhttp,localhost, and the port fromResolveWarmupPort(:104-109); theSocketsHttpHandlerandHttpClientare created locally withdisposeHandler: falseand both disposed byusing(:111-117), and the client carriesDefaultRequestVersionandDefaultVersionPolicyfrom the two virtuals (:115-116). - The replay loop (
:119-133): each path is issued as a relativeGET(:121-123). WhenRequireSuccessStatusCodeis 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). AnOperationCanceledExceptionis 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 CA1031whose 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 thetrystarts (: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 staticso it can be tested directly. It prefers the firsthttp://address from the server'sIServerAddressesFeature(:159-160), which is the only correct answer under Aspire's dynamic ports, falls back toSelectCleartextUrl(configuration["ASPNETCORE_URLS"])(:161), then parses the port by trimming a trailing slash and taking the last colon-delimited segment underCultureInfo.InvariantCulture(:163), and finally falls back toDefaultPortwhen nothing parses (:165).SelectCleartextUrl(string?)(:176-187) picks the cleartext entry out of a possibly semicolon-separatedASPNETCORE_URLSlist (:183), returning the firsthttp://entry or, if there is none, the first entry at all (:185-186). Its doc comment explains why this stays string handling rather thanUriparsing (:168-173): wildcard hosts such as+and*are legal in that variable andUrirejects 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".
- Two constants.
- 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 areinternalbecause 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 globalConfigureHttpClientDefaultspipeline, whose resilience handler and shared primary handler are tuned for outbound service-to-service calls, not for one loopback request at startup. internal staticport 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 exerciseResolveWarmupPortalone (MMCA.Common/Tests/Hosting/MMCA.Common.Aspire.Tests/Warmup/SelfHttpWarmupTaskBaseTests.cs:68-113), covering the bound-address preference, the HTTPS skip, bothASPNETCORE_URLSfallbacks, 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 atProgram.cs:157) and a SelfHttpWarmupTask in Identity (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/SelfHttpWarmupTask.cs:39,:42, registered atProgram.cs:169), both of which overrideRequireSuccessStatusCodetofalsebecause their one path is protected (:49in each). Store has three more of the same shape (IdentityProgram.cs:144, CatalogProgram.cs:162, SalesProgram.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 withRequestVersionOrLowerbecause 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 asHttp2orHttp1. - Caveats / not-in-source:
- A required-success failure abandons the remaining paths.
EnsureSuccessStatusCodethrows 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
WarmupPathsmatch 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.
- A required-success failure abandons the remaining paths.
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
BackgroundServicethat 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 parametersTimeProvider? timeProviderandTimeSpan? 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 leaveTask.WhenAllpending 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 = nullandTimeSpan? 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 theprivate 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 overallStopwatch(:49), runsTask.WhenAll(tasks.Select(task => RunOneAsync(task, stoppingToken)))(:53-54), and, crucially, opens the gate in afinallyblock,gate.MarkReady()(:58) followed byLogWarmupCompletewith 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 ownStopwatch(:65), then awaitstask.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 rethrowsOperationCanceledExceptionwhen the host is actually stopping (:73-76, thewhen (cancellationToken.IsCancellationRequested)filter), so shutdown is not mistaken for a task failure. ATimeoutExceptionfrom theWaitAsyncceiling is caught and logged at Warning throughLogTaskTimedOutwith 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 CA1031and 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), andLogTaskTimedOut(:105, EventId 4, Warning, carrying the exception plus the limit in seconds so the log line names the ceiling that fired).
- Constructor and timeout state first: the primary constructor (
- Why it's built this way:
internal sealed partial. Running the tasks withTask.WhenAllrather 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-taskWaitAsyncis 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: theWaitAsyncoverload takes aTimeProvider, 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 inExtensions.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 onTimeProvider.Systemand the 120-second default. Covered by WarmupHostedServiceTests, which constructs the service directly and passes a shorttaskTimeoutto exercise the hanging-task case. - Caveats / not-in-source:
WaitAsyncabandons rather than cancels, so a timed-out task keeps running detached for the life of the host (the comment at:79-80says exactly this); the only token that can stop it is the host'sstoppingTokenat 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, sinceTaskTimeoutSecondsis aprivate const(:42) and thetaskTimeoutparameter 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
IHealthCheckthat reports unhealthy until the WarmupReadinessGate opens. It is registered with thereadytag so it appears on the/health/readyendpoint that ACA readiness probes hit. - Depends on: WarmupReadinessGate (primary-constructor injection);
IHealthCheckandHealthCheckResult(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), returningTask.FromResult(gate.IsReady ? HealthCheckResult.Healthy("Warm-up complete.") : HealthCheckResult.Unhealthy("Warm-up in progress.")). It is synchronous under an async signature because reading a volatileintinvolves no I/O;Task.FromResultavoids 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/readypayload, so they are written for a human reading a probe failure. - Where it's used: Registered as the
"warmup"check taggedHealthCheckTags.ReadybyAddWarmupReadiness(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:115-116); surfaced at/health/readybyMapDefaultEndpoints(Extensions.cs:433-435), whose predicate admits every check tagged neitherlivenoroptional(: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), andRandomNumberGenerator(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 atSecurityHeaders.cs:130-131states 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, andNonceByteCount = 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-guardsoptionsandenvironment(: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 pinlocalhostto HTTPS in the browser for a year. InvokeAsync(:162) null-guards the context (:164), takes the response header collection once (:166), and setsXContentTypeOptions = "nosniff"(:167),XFrameOptions(:168),Referrer-Policy(:169), andPermissions-Policy(:170); it conditionally setsStrictTransportSecurity(: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 freshConvert.ToBase64String(RandomNumberGenerator.GetBytes(16))value is generated (:186), stored underCspNonce.ItemKeyinHttpContext.Items(:187), and substituted into every occurrence as'nonce-<value>'(:188). The comment above it (:180-182) records why the raw value lands inItemsbefore the pipeline runs: the page render is what stamps it onto its script and style tags. Finally the header is written asContentSecurityPolicywhenEnforceis true (:193) andContentSecurityPolicyReportOnlyotherwise (: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.
- Two private constants frame the nonce feature:
- Why it's built this way: A sealed conventional middleware (constructor plus
InvokeAsync), not anIMiddleware, so it is a singleton per pipeline with zero per-request resolution. Resolving CSP through the injectedICspPolicyProviderrather than reading settings directly is what makes per-host dynamic policies possible while every other header stays uniform. Computing_enableHstsonce 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, andMMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:145. Covered by SecurityHeadersMiddlewareTests inMMCA.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.Headersafter 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
nullwhen 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? _policyfield (SecurityHeaders.cs:81). The constructor (:83) null-guardsoptions(:85), readsoptions.Value.ContentSecurityPolicy(:86), and sets_policytonullwhen that string is null or whitespace, otherwise to a new CspPolicy capturing the string and theEnforceContentSecurityPolicyflag (: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 byTryAddSingletonso a custom provider registered first by a Blazor host wins. Computing the policy once in the constructor rather than per request makesGetPolicyallocation-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
.AddCommonSecurityHeadersviaTryAddSingleton(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 thanIOptionsMonitor<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) andUseCommonSecurityHeaders(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,AddCommonSecurityHeadersduring service registration andUseCommonSecurityHeadersin the pipeline, is the idiomatic ASP.NET Core split, expressed here with twoextension(T)blocks in one static class (SecurityHeaders.cs:212,:242), which is also why the class carries the scopedCA1708suppression with its false-positive justification (:206-209). - Walkthrough:
AddCommonSecurityHeaders(configuration = null, configure = null)(SecurityHeaders.cs:220): null-guards the receiver (:224), builds anAddOptions<SecurityHeadersSettings>()(:226), binds the"SecurityHeaders"configuration section whenconfigurationis supplied (:228-230), applies the optionalconfiguredelegate when supplied (:232-235), then callsTryAddSingleton<ICspPolicyProvider, StaticCspPolicyProvider>()(:237). TheTryAddis 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 bareAddCommonSecurityHeaders().UseCommonSecurityHeaders()(:245): null-guards the app (:247) and returnsapp.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). TheTryAddSingletonordering 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 isTryAdd-registered, a host must register its customICspPolicyProviderbefore callingAddCommonSecurityHeaders, 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 callAddCommonBlazorCsp()first (ADCProgram.cs:99, StoreProgram.cs:139, each with the ordering comment right above it at ADC:97-98and 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
staticproperties 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,doubleandint(BCL). Read by OutboxProcessor inMMCA.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,MaxRetriesthen 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
staticproperties, 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
staticproperties rather than fields or constants, for the same reason as its sibling:TimeSpancannot be aconst, and a property is evaluated at call time instead of being inlined into the consuming assembly. Living inMMCA.Common.Sharedrather 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 throughLogBrokerCircuitOpen(:664). The pipeline also excludesOperationCanceledExceptionfrom 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 theMMCA.Common.Brokermeter that Common.AspireExtensionssubscribes 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.IApplicationBuilderandMicrosoft.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 memberUseCommonForwardedHeaders()(:36-41) null-guards the builder (:38) and returnsapp.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 headersXForwardedFor | XForwardedProto | XForwardedHost(:59), then clears both allow-lists:options.KnownProxies.Clear()(:62) andoptions.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
CA1708suppression (:19-22) with the same justification used elsewhere in the kit: with anextension(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
UseCommonMiddlewarePipelineinMMCA.Common.API, but a gateway takes none of that package: no controllers, noDbContext, 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) andMMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:150. In both hosts the edge limiter (app.UseGatewayRateLimiting(), ADCProgram.cs:143, StoreProgram.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) andTimeSpan. Held by GatewayHealthCheckDefaults; materialised into YARP'sActiveHealthCheckConfigby GatewayHealthCheckDefaultsConfigFilter. - Concept introduced, probing liveness and never readiness.
[Rubric §13, Observability & Operability]assesses whether an operator can tell a healthy deployment from an outage. ThePathdoc comment (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewaySettings.cs:165-169) is the teachable line in this whole family: the probe targets/aliverather than/healthon 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) andTimeout = 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": trueand inherits a sane probe, rather than restating five properties. Both consumer gateways do exactly that and override only the interval: ADC setsEnabled: trueplusInterval: 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 returnsnullwhenEnabledis 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
internalparsers that turn the two text properties into their strongly typed forms with a cluster-naming error message. - Depends on:
System.Net.HttpVersionPolicyandSystem.Version(BCL). Held by GatewaySettings as bothClusterRequestDefaultsand the values ofClusterRequestOverrides; merged into YARP'sForwarderRequestConfigby 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 thanVersion/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): returnsnullwhenVersionis unset (:78-81), otherwiseSystem.Version.TryParse(:83) or anInvalidOperationExceptionwhose message names the cluster and the two valid shapes (:85-87).ParseVersionPolicy(string clusterId)(:96-111): the same shape overEnum.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.clusterIdexists 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 identicalHttpRequestblock 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:ClusterRequestDefaultsandMmcaGateway:ClusterRequestOverrides:{clusterId}. ADC declares a00:01:40REST activity timeout as the default and a one-hour override for the long-lived SignalR hub clusternotification-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) andTimeSpan. 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'sProgram.cscomment describes exactly that outcome (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:106-108). - Caveats / not-in-source:
TransportFailureRatehas 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) orGlobal = 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 onClientIprecords 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 = 0is declared first and is therefore the CLR default for the enum, which lines up with GatewayRoutePolicySettings's own explicit default (:215).Global = 1is 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 theirauth-tightpolicy 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) andDefaultClusterHeaderName = "X-MMCA-Cluster"(:184):const stringdefaults, 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) andClusterHeaderName(: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 becauseX-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 runX-MMCA-Route/X-MMCA-Cluster, which ADC'sProgram.cscomment 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
staticproperties that are the single source of truth for the outbound-HTTP resilience window (attempt timeout, breaker window, total timeout, retry count) and for theSocketsHttpHandlerconnection 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.AspireExtensionsservice-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
staticproperties 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
HttpRequestExceptionor 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) andKeepAlivePingTimeout= 30 s (:43): the HTTP/2 socket-level keep-alive ping interval and the timeout waiting for its acknowledgement.
- The Polly window, the values handed to
- Why it's built this way:
- The layer rules chose the address.
MMCA.Common.Aspire(Hosting) andMMCA.Common.Grpc(Presentation) sit in different layers and neither may reference the other;Sharedis the one layer both are allowed to depend on, which the class doc states outright (:6-7). Both project files carry exactly that singleProjectReference, 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.
TimeSpancannot be aconstat all, and an expression-bodiedstaticproperty is evaluated at call time rather than inlined into the consuming assembly the way aconst intis 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.
- The layer rules chose the address.
- Where it's used: three call sites, two of which read it.
AddServiceDefaultsroutes it throughConfigureHttpClientDefaults, so everyHttpClientin the process inherits it (typed clients, named clients, the YARP forwarder, per the comment atExtensions.cs:53-54): the four Polly values go intoAddStandardResilienceHandler(Extensions.cs:65-71) and the four socket values intoConfigurePrimaryHttpMessageHandler(() => 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 globalConfigureHttpClientDefaultsinstalled, so the connection-hygiene values must come from the same source of truth (the reasoning is spelled out atDependencyInjection.cs:101-109).- Not a reader:
AddTypedServiceClient<TInterface, TImplementation>in Infrastructure (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:833) callsbuilder.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 fromAddServiceDefaults(:803-804). Not determinable from source: how that second standard handler and the globalConfigureHttpClientDefaultspipeline 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
AddTypedGrpcClientregisters 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.WithActiveRequestsandEnableMultipleHttp2Connections = true(Extensions.cs:90-91,DependencyInjection.cs:105,110). The first one matters when reading the ping values: underWithActiveRequeststhe pings apply to connections that have outstanding requests, so what holds a genuinely idle pooled connection open isPooledConnectionIdleTimeout, notKeepAlivePingDelay. - 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.
- No test pins the numbers. The ADR-009 fitness function ResilienceHandlerTests asserts that
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:HealthCheckDefaultsmirrors YARP's ownHealthCheckshape 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 wholeHealthCheckDefaultssection 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 theMmcaGatewaysection recognises it from theReverseProxysection, 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
RateLimiterPolicyroute property ("RateLimiterPolicy": "auth-tight"). Four validated init-only properties plus theinternalpartition-key selector that decides what a given request counts against. - Depends on: GatewayRoutePolicyPartition,
System.Net.IPAddress, and theRangeAttributedata 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 isPartitionKey, 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 returnsnull, meaning no limiter at all. The alternative, bucketing every unattributable request under one shared key, sounds safer but is not: it throttles an in-processTestServer(whereRemoteIpAddressis 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 toGatewayRoutePolicyPartition.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 byPartition = Global, and reused as the (unused) key of the no-limiter partition inGatewayRoutePolicyExtensions.Partition(MMCA.Common/Source/Hosting/MMCA.Common.Gateway/RateLimiting/GatewayRoutePolicyExtensions.cs:89).PartitionKey(IPAddress? remoteIpAddress)(:243-246),internal: returnsGlobalPartitionKeyfor a global policy, otherwiseremoteIpAddress?.ToString(), which isnullwhen 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, becauseAddGatewayRoutePoliciesrunsValidator.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/Authroutes. The Aspire AppHost extensionWithE2eGatewayRateLimitLiftraises that policy'sPermitLimitto 100000 for E2E runs by injectingMmcaGateway__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
staticproperties 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,SamplingDurationandMaxRetryAttemptsare 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 toHttpResilienceDefaults.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.
- Delegated (four):
- Why it's built this way: the same layer reasoning as its sibling.
MMCA.Common.Grpcmay depend only onMMCA.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 fromHttpResilienceDefaultsrather 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 theMmcaGatewaysection. 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 YARPReverseProxysection. 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,constso 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 eagernew(), 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 withStringComparer.Ordinal, deliberately case sensitive unlike the cluster overrides, because a route references the policy by exact name through YARP's ownRateLimiterPolicyproperty.
- 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-56andMMCA.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:
ValidateDataAnnotationsvalidates the top-level object; the[Range]annotations on the rate-limiter policies are additionally validated explicitly insideAddGatewayRoutePolicies(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
IProxyConfigFilterthat 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/RouteConfigandYarp.ReverseProxy.Forwarder.ForwarderRequestConfig, plusMicrosoft.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 registeredIProxyConfigFilterover 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), callsResolve(:37), then returns the same instance when nothing changed andcluster 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),internalso the precedence rules are unit-testable through the filter's own type (:58): reads the cluster's ownHttpRequest(:62), looks up the per-cluster override by id (:64), takesClusterRequestDefaultsas the fallback (:65), and builds oneForwarderRequestConfigfrom 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'sParseVersion/ParseVersionPolicywith 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 substitutesForwarderRequestConfig.Emptyfor 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).
- Primary constructor
- 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
AddMmcaGatewaythroughbuilder.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>, notIOptionsMonitor<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
IProxyConfigFilterin 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'sIProxyConfigFilter,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 aPassiveblock keeps it verbatim, and the same holds forActive. 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 partialPassiveblock 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 existingHealthCheck(:29), thenexisting?.Passive ?? BuildPassive()(:30) andexisting?.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 withReferenceEquals. Otherwise it rebuildsHealthCheckwith the two blocks and carriesAvailableDestinationsPolicyacross 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 aPassiveHealthCheckConfigfrom the settings whenEnabled, elsenull. Returningnullmatters: it means "no passive block", which is different from a block withEnabled = false.BuildActive()(:74-87): the same shape, copyingPolicy,Interval,TimeoutandPath.
- Primary constructor and null-guarded settings field (
- 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 ownsHttpRequest, the health-check filter ownsHealthCheck, 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 realActiveHealthCheckConfigon 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:
ReferenceEqualson the two blocks is a correct no-op check only becauseBuildPassive/BuildActiveconstruct 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-propertyIsSameAs.
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 theinternal static Partitionhelper that decides what one request counts against. - Depends on: GatewaySettings and GatewayRoutePolicySettings. Externals:
System.Threading.RateLimiting(RateLimitPartition,FixedWindowRateLimiterOptions,QueueProcessingOrder),Microsoft.AspNetCore.RateLimitingviaAddRateLimiter,HttpContext, andValidatorfrom 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 exampleMMCA.Common.Aspire.Gateway.AddGatewayRateLimiting, which assignsRateLimiterOptions.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 anAddPolicyblock. - Walkthrough:
AddGatewayRoutePolicies(GatewaySettings settings)(:39-69) inside theextension(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 callsAddRateLimiterat 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-rangePermitLimitthrows at registration rather than at the first throttled request. The doc names the governing decision (:34-35), ADR-070 fail-fast. - Calls
AddRateLimiter(:54), setsRejectionStatusCode = StatusCodes.Status429TooManyRequests(:56), and registers oneoptions.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.
- Null-guards both the collection and the settings (
Partition(HttpContext, GatewayRoutePolicySettings)(:81-98),internalso the partition-key selection is unit-testable (:80): asks the policy for the key fromhttpContext.Connection.RemoteIpAddress(:86), then branches (:88-97). Anullkey yieldsRateLimitPartition.GetNoLimiter(...), the fail-open path GatewayRoutePolicySettings explains; otherwise aGetFixedWindowLimiterbuilt from the policy'sPermitLimit,WindowSecondsandQueueLimit, withQueueProcessingOrder.OldestFirstandAutoReplenishment = 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 = truemeans the window refills on its own timer rather than requiring the host to pump it.RejectionStatusCodeis 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 scopedCA1708suppression as the otherextension(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 bothAddMmcaGatewayoverloads register it. In the consumer gateways the resultingauth-tightpolicy is enforced by the singleapp.UseGatewayRateLimiting()call that also serves the global edge limiter (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:143with the explanation at:140-142,MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:170). Covered by GatewayRoutePolicyTests. - Caveats / not-in-source:
AddRateLimiteris 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 forgotUseRateLimiter(). 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
AddMmcaGatewayoverloads onIReverseProxyBuilder, one binding fromIConfigurationand one taking already-built settings, over one shared privateWiremethod. - 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 otherIProxyConfigProvider, 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 theMmcaGatewaysection (:52), registersAddOptions<GatewaySettings>().Bind(section).ValidateDataAnnotations().ValidateOnStart()(:54-57), then callsWirewithsection.Get<GatewaySettings>() ?? new GatewaySettings()(:59). The eagerGet<T>is necessary because the named rate-limiter policies must be enumerated at registration time, before anyIOptions<T>can be resolved.AddMmcaGateway(GatewaySettings settings)(:68-79): for a host composing its settings in code. It registers an explicitIOptions<GatewaySettings>singleton viaOptions.Create(settings)(:76) rather than aConfigure<T>callback, and the inline comment says why (:73-75):GatewaySettingsis 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: callsAddGatewayRoutePolicies(settings)on the service collection (:88), then chainsAddConfigFilter<GatewayClusterProfileConfigFilter>()(:91),AddConfigFilter<GatewayHealthCheckDefaultsConfigFilter>()(:92) andAddTransforms<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 callsMapReverseProxy(), andUseRateLimiter()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 scopedCA1708suppression (:22-25) is the sameextension(T)analyzer false positive documented across the kit. - Where it's used: both consumer gateways chain it between
LoadFromConfigandAddServiceDiscoveryDestinationResolver:MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:112-116(with a numbered summary of what it adds at:98-111) andMMCA.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-Routewith the route id YARP matched andX-MMCA-Clusterwith 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'sITransformProvider,TransformBuilderContext,TransformRouteValidationContext/TransformClusterValidationContextand theAddRequestTransformextension (Yarp.ReverseProxy.Transforms/.Transforms.Builder,:1-3), plusMicrosoft.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: anITransformProvideris 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 setsX-MMCA-Route: identity-authitself 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_settingsis initialized tooptions.Value.TraceHeaderswith an inlineArgumentNullExceptionguard (:18-21). The provider therefore holds the trace-header sub-settings only, resolved once at construction: the DI registration suppliesIOptions<GatewaySettings>as a fixedOptions.Create(settings)singleton (GatewayReverseProxyExtensions.cs:191), so there is no reload path to honour. ValidateRoute(:25) andValidateCluster(: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.Enabledis 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
RouteIdandClusterId(: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: taketransformContext.ProxyRequest.Headers(:58),Removeboth header names (:60-61), thenTryAddWithoutValidationeach id back only when it is non-empty (:63-71), and returnValueTask.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.
- A primary constructor takes
- 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.
TryAddWithoutValidationskips the header-syntax checks, which matters because route and cluster ids come from operator-authored configuration and a strictAddwould 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
Wirebody shared by bothAddMmcaGatewayoverloads, 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:114andMMCA.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), thatEnabled = falseleavescontext.RequestTransformsempty (: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:TraceHeaderssection, so the shipped names are the defaultsX-MMCA-RouteandX-MMCA-Cluster(GatewaySettings.cs:181,:184) andEnabledstays 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 theEnabledgate 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>andSystem.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 everyActivityaRecordedflag, and the SDK's batch export processors skip anything unrecorded. Clearing that flag in an earlier processor'sOnEndis 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 aSqlClientdependency 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-messageOutboxProcessspans 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 stringfields,OutboxActivitySourceName = "MMCA.Common.Outbox"andPollActivityName = "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.Aspiredoes not referenceMMCA.Common.Infrastructure, soAddServiceDefaults()stays usable from a host that never takes the persistence stack (EF Core, MassTransit, SignalR/Redis). Its one project reference isMMCA.Common.Shared, for HttpResilienceDefaults. The same two literals also appear in theAddMeter/AddSourcecalls inExtensions.cs. The counterparts they must stay in sync with areOutboxProcessor.PollActivityName(OutboxProcessor.cs:75) and the activity sourcenew("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: theSqlClientspan the Azure Monitor distro creates can sit more than one level below the poll. - The predicate tests both
OperationName == PollActivityNameandSource.Name == OutboxActivitySourceName(:39-40). The comment (:35-36) names the failure it guards against: a consumer span that happens to be calledOutboxPollfrom some other source must not be swept up. - On a match it clears the flag with
data.ActivityTraceFlags &= ~ActivityTraceFlags.Recordedand 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 itsOnEndruns first.
- Two
- 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
Recordedrather 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'sWithTracingblock (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 beforebuilder.AddOpenTelemetryExporters()(:265). Every host that callsAddServiceDefaults()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 backlogCOUNT(OutboxProcessor.cs:366), bothusing var pollActivity = OutboxActivitySource.StartActivity(PollActivityName). Covered by OutboxPollFilterProcessorTests, which drives a realActivityListenerand 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 realOutboxProcessspan 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 theSqlClientchild 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 againstOutboxProcessor'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.
ShouldCollectRequestrefuses an inbound health-probe request and leaves a marker tag behind on the way out;ShouldCollectOutgoingrefuses an outbound call to a probe endpoint. Together they are the front half of theTelemetry:FilterProbeTelemetrycost 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/ActivityKindandMicrosoft.AspNetCore.Http.HttpContext(:1-2), plusHttpRequestMessagefor 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 itsRecordedflag 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 returningfalsestops 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 everyAppRequestsrow in both production workspaces (:6-12). They are also invisible to the sampling knob: head-based sampling keeps a proportion of everything, so aTracesSampleRatioof 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, sohttp.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) returnstrue(collect) for a null context or any non-probe path, delegating the path test toHealthEndpointPaths.IsProbePath(context.Request.Path.Value)(:42-45). Only when the path is a probe does it continue.- Before returning
falseit stamps the marker onActivity.Current, guarded by a pattern match onKind: 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, soActivity.Currenthere 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 SQLSELECT 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 noRequestUriis collected, so the predicate never suppresses something it cannot classify.PathOf(Uri uri)(:72) extracts that path. An absolute URI yieldsAbsolutePathdirectly (:74-77); a relative one (legal on a client with aBaseAddress) 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 newUriwould 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.
ShouldCollectRequesthandles probes received, whose children are handled downstream by the processor.ShouldCollectOutgoinghandles 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 isinternal 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'sWithTracingblock, but only on the enabled branch:options.Filter = Telemetry.ProbeTelemetryFilter.ShouldCollectRequestandoptions.FilterHttpRequestMessage = Telemetry.ProbeTelemetryFilter.ShouldCollectOutgoing(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:229-233). Theelsebranch 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 isTelemetry:FilterProbeTelemetry(Extensions.cs:35), read byIsProbeTelemetryFilterEnabled(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.Currentonly, 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,/healthand 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'sBaseProcessor<Activity>andSystem.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
Recordedso 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 theAppDependenciesvolume 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 stringfields,UrlPathTagName = "url.path"andHttpRouteTagName = "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. OnStartandOnEndare both one-liners delegating to the sameSuppressWhenUnderProbe(data)(:29,:40). TheOnEndremarks (: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 anOnStart-only filter would depend on callback ordering it does not control. ClearingRecordedat end is what actually keeps the span out of the exporters; theOnStartpass 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.Recordedmakes the batch export processors skip the activity, anddata.IsAllDataRequested = falsestops 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 requiresKind == ActivityKind.Serverand then tries three probe-path candidates in turn: theurl.pathtag, thehttp.routetag, 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.
- Two
- 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'sWithTracingblock, astracing.AddProcessor(new Telemetry.ProbeTelemetryFilterProcessor())guarded by the samefilterProbeTelemetryflag 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 andAddOpenTelemetryExporters(). The comment there restates the ordering contract (Extensions.cs:250-252): registration before the exporters is what makes the clearedRecordedflag the thing their batch processors see. Every host callingAddServiceDefaults()gets it unless it setsTelemetry:FilterProbeTelemetry=false. Covered by ProbeTelemetryFilterProcessorTests, which drives a realActivityListenerand 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), byhttp.routealone (: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 theOnEndpass (: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/healthor/aliveand whose kind isServerwould also be suppressed.
⬅ Common UI Framework (MudBlazor components, theme, base pages) • Index • ADC Conference - Domain Model & Module Contracts ➡