Architecture Decision Record
ADR-088: Gateway Edge Responsibilities (and the Three It Declines)
Status
Accepted (2026-08-18). Extends ADR-019 with a fourth, edge-tier layer whose
posture is the deliberate opposite of the service tier's authenticated-only global limiter; nothing in
ADR-019's three service-tier layers changes. It also extends
ADR-041's correlation id one hop outward, to the process that
sees a request first and its response last. ADR-004's validation
authority is unchanged on purpose: the gateway does not validate tokens, recorded below as a
rejection with a named trigger rather than as an omission. The framework half shipped in v1.154.0
(MMCA.Common/CHANGELOG.md) and both consumer gateways were wired to it in the same wave, so the
adoption statements below describe what each gateway does today.
Revised 2026-08-27 (v1.163.0): the framework now ships a dedicated gateway package,
MMCA.Common.Gateway, beside the MMCA.Common.Aspire edge kit this record originally described.
Three things change below: the one-package constraint in the Context is retired, a composition entry
point (AddMmcaGateway) joins the Decision, and the declines gain a companion list of
delegations, behaviors the gateway deliberately leaves to a layer better placed to perform them,
recorded so an audit reads them as decisions rather than as gaps. Both consumer gateways reference
the package in package mode (MMCA.ADC/Directory.Packages.props:125,
MMCA.Store/Directory.Packages.props:14).
Revised 2026-08-31: both consumers pin MMCA.Common.Gateway in lockstep with the rest of the
framework rather than on a version of its own (1.185.0 today, at the two lines cited above), and the
source citations below are refreshed against current line numbers. Nothing in the decision changes.
Revised 2026-09-03: the citations are refreshed again: the rate-limiting kit's anchors all moved when the synthetic-traffic bypass tier (the 2026-09-01 amendment below) landed in the same two files. Nothing in the decision changes. Revised 2026-09-07 (health endpoints serve a cached report with single flight while keeping their rate-limit bypass, and edge-shaped controls are no longer gateway-only: Store's storefront host carries its own limiter and a circuit cap).
Revised 2026-09-11: the bypass tiers are four, not three. The 2026-09-07 security review added a trusted-internal-caller exemption beside the synthetic-traffic one, recorded as a second amendment below. Every rate-limiting citation is refreshed against current line numbers, which moved when that code landed. Nothing else in the decision changes.
Revised 2026-09-19: both consumers pinned MMCA.Common.Gateway at 1.205.0 on that date, still in
lockstep with the rest of the framework rather than on a version of its own; the current pin is
1.216.0 in both (MMCA.ADC/Directory.Packages.props:125, MMCA.Store/Directory.Packages.props:14),
and the 1.185.0 figure above is the 2026-08-31 snapshot. Nothing in the decision changes.
Revised 2026-09-25: the bearer-delegation paragraph now records the one edge-authorization
difference between the two gateways: ADC's host evaluates the anonymous policy each route declares
through an authorization middleware pair, while Store's routes declare the same policy and its host
registers no authorization middleware, by design. Neither authenticates anyone. The ADC gateway
citations in the load-balancing delegation and the adoption trade-off are refreshed against current
line numbers. Nothing in the decision changes.
Revised 2026-10-01: both consumers now turn active destination probing off; see the Revision (2026-10-01) below.
Context
ADR-008 made the Gateway the only client entry point and gave it
three jobs: the route-to-service map, CORS, and forwarding the caller's Authorization header. Nothing
was added to it since. Meanwhile the service tier accumulated a standardized cross-cutting pipeline
(correlation id, rate limiting, forwarded headers, tenant resolution, output cache) that
ADR-079 fixed into one ordered method, and that record
scopes the gateways out of it deliberately: a reverse proxy has no controllers, no localization and
no tenant context, so composing the service chain there would be wrong.
Scoping the gateways out was right, and it left a real gap, because three of those behaviors are not
service concerns at all. They are edge concerns the service tier had been performing one hop too late.
Before this record the gateway chains were four calls long and contained none of them: ADC registered
security headers, default endpoints, CORS, static files and a privacy endpoint around its forwarders
(MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:77, :117, :119, :120, :129, :134-135),
and Store registered security headers, default endpoints and CORS
(MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:58, :64, :140, :142, :143; both sets
of line numbers are as of the original 2026-08-18 record, before the edge kit landed). Neither
had a rate limiter or a correlation id of any kind.
A correlation id minted per service is not a correlation id. CorrelationIdMiddleware (ADR-041)
runs inside each service host and falls back to the W3C trace id when the client sends no
X-Correlation-ID. A browser call crossing the Gateway into two services therefore produced two
independent ids, neither present in the Gateway's own logs, and an operator holding the id from one
service could not find the request's first hop. The one process guaranteed to see every request
exactly once was the only one not stamping it.
ADR-019's anonymous exemption is correct one hop in and wrong at the edge. That record exempts anonymous traffic from the global limiter for two stated reasons: Blazor Server fronts public browsing behind a single IP, so an anonymous IP cap would throttle real visitors, and public reads are served from the output cache anyway, so the backend they reach is already cheap. Both are properties of a service sitting behind the Gateway. At the edge neither holds. The output cache that made an anonymous read cheap lives behind the proxy, so a flood is paid for in full by the Gateway (accept, route, forward, copy the response) before anything can be served from cache, and the fleet's only shared choke point had no cap at all on the traffic class that most needs one.
A gateway that is "healthy" while every downstream is unreachable still receives traffic. The Gateway's readiness endpoint reported only that the Gateway process was up. Azure Container Apps then routed to it and it forwarded to services that were not answering, converting a downstream outage into a wall of 502s from a replica the platform believed was ready.
One packaging constraint shapes where the fix can live. A YARP host has no controllers, so it does not
take MMCA.Common.API, where CorrelationIdMiddleware and AddCommonRateLimiting live. At the time
of writing it referenced MMCA.Common.Aspire and nothing else in the framework, so anything the edge
owned had to be reachable from the Aspire package alone.
That constraint is retired (2026-08-27). A gateway host now takes two framework packages,
MMCA.Common.Aspire for the host-level kit above and MMCA.Common.Gateway for the YARP-level
composition below (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/MMCA.ADC.Gateway.csproj:3-4,
MMCA.Store/Source/Hosts/MMCA.Store.Gateway/MMCA.Store.Gateway.csproj:25-26). The split is along a
real boundary rather than a packaging convenience: the Aspire kit registers host middleware and
health checks that any ASP.NET Core process could use, while the Gateway package registers YARP's own
extension points (config filters, transforms, per-route limiter policies) and therefore has to
reference YARP, which a service host has no reason to carry. The Gateway package also carries one
piece of host middleware, UseCommonForwardedHeaders()
(MMCA.Common/Source/Hosting/MMCA.Common.Gateway/ForwardedHeadersExtensions.cs:36), which both
gateway hosts call first so the per-client-IP window sees the real caller behind the ingress
(MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:158,
MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:150).
Decision
Ship a gateway edge kit in a Gateway namespace inside MMCA.Common.Aspire, owning exactly three
responsibilities, and record three more as deliberately declined. A fourth section, added 2026-08-27
with the MMCA.Common.Gateway package, records what the edge delegates: behaviors it does not
perform because another layer already performs them better, which is a different statement from
declining to own a behavior nobody performs.
What the edge owns
1. Correlation is ensured, not merely read. GatewayCorrelationMiddleware
(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Gateway/GatewayCorrelationMiddleware.cs) declares
X-Correlation-ID as a constant (:34), and when the header is absent it mints one from
Activity.Current?.TraceId, falling back to HttpContext.TraceIdentifier (:48-55), the same
precedence ADR-041's service middleware uses. The mechanism that makes it one id is that the value is
written back onto the request headers before forwarding: the downstream service's own middleware
then finds a header already present and adopts it instead of minting its own. The response echo is
registered through Response.OnStarting (:58-62), and the registration extension is
UseGatewayCorrelation() (:82).
The middleware is context-free, and that is a constraint rather than an accident of scope. Its only
constructor dependency is the RequestDelegate (:27): no HttpContext.Items, no logger, no scoped
service. The service-tier version sets a scoped ICorrelationContext that the CQRS logging decorators
read, and the Aspire package cannot reference the Application layer that declares that abstraction. The
edge version is therefore a deliberately smaller thing than its namesake, not a copy of it, and it
composes with a host that has no DI graph beyond YARP.
2. Rate limiting at the edge counts anonymous callers, and chains a global concurrency cap behind
it. AddGatewayRateLimiting installs a per-client-IP fixed window partitioned on
Connection.RemoteIpAddress with no authentication exemption of any kind
(ClientIpPartition, .../Gateway/GatewayRateLimitingExtensions.cs:195, the address read at :215,
limiter at :223), chained through PartitionedRateLimiter.CreateChained (:306) with a
process-wide concurrency limiter (ConcurrencyPartition, :241-256, the limiter at :250),
rejecting overage with 429 (:301). The two answer different failures: the window answers one noisy
source, and the concurrency cap answers total in-flight work regardless of how many sources produced
it, which is the failure a per-IP window structurally cannot see. Defaults live in
GatewayRateLimitingSettings (section "GatewayRateLimiting",
.../Gateway/GatewayRateLimitingSettings.cs:50): PermitLimit 120 (:59) per WindowSeconds 60
(:63), GlobalConcurrencyLimit 200 (:73).
The settings are validated twice, because there are two ways in. The configuration overload binds
through AddOptions().Bind(section).ValidateDataAnnotations().ValidateOnStart()
(GatewayRateLimitingExtensions.cs:273-276), so a host with an out-of-range value refuses to boot,
which is ADR-070's contract exactly. That alone would not be
enough here: the limiter closes over an eagerly-bound copy rather than resolving IOptions per
request, and a caller can hand settings straight to the object overload without passing through the
options pipeline at all. So the overload every path funnels into runs
Validator.ValidateObject(settings, ..., validateAllProperties: true) at registration (:297, with
the reasoning stated inline at :294-296). The [Range] bounds on the three numeric settings
(GatewayRateLimitingSettings.cs:58, :62, :72) are therefore load-bearing on both paths: an
invalid PermitLimit throws where it is configured, not at the first throttled request.
Bypasses are two-tier as first recorded, and two secret-gated tiers joined them in the amendments
below (synthetic traffic on 2026-09-01, a trusted internal caller on 2026-09-07), so four exist
today. The tiers are different kinds of thing. Infrastructure bypasses are
unconditional: /health, /alive and /.well-known are hard-coded
(GatewayRateLimitingExtensions.cs:69, matched by path segment, case-insensitively, IsBypassed at
:81-90, the comparison at :89), because
throttling them takes down probes and token validation (ADR-004's JWKS discovery) as a side effect of
throttling traffic. Application bypasses are configuration, through BypassPathPrefixes
(GatewayRateLimitingSettings.cs:82, empty by default), and each consumer sets its own list in the
gateway's appsettings.json beside the ReverseProxy route table that same file now declares
(ADR-089). The two entries are recorded here so they
are not rediscovered as incidents: Store's Stripe webhook route (/Payments/{**catch-all}, bypassed
by the "/Payments" prefix at
MMCA.Store/Source/Hosts/MMCA.Store.Gateway/appsettings.json:20, route at :139-143), because a 429
to Stripe is a retry and eventually a disabled endpoint, which silently stops every payment update
(ADR-084); and ADC's SignalR hub route (/hubs/{**catch-all},
bypassed by the "/hubs" prefix at
MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:25, route at :254-258), because a
negotiate-plus-reconnect storm from one office's shared address is exactly the pattern a per-IP window
misreads as abuse (ADR-039).
A request with no attributable client IP is not limited. It gets
RateLimitPartition.GetNoLimiter (GatewayRateLimitingExtensions.cs:220, the reasoning inline at
:218-219) rather than sharing one
bucket with every other unattributable request, which is the same fail-open posture ADR-019 chose for
auth-ip and for the global limiter's fallback key, for the same reason: a shared "unknown" bucket is
a single tripwire that one misbehaving caller pulls for everyone behind it.
Amendment (2026-09-01): a third, secret-gated bypass tier for synthetic traffic. The two tiers
above assume every caller is a real client. A capacity proof is not: both consumers run a monthly
single-runner k6 read-load test against production through the gateway
(MMCA.ADC/.github/workflows/load-test.yml, MMCA.Store/.github/workflows/load-test.yml), and the
whole run arrives from ONE runner IP at roughly 100 requests per second, which the per-IP window
(120 per 60 s) reads as exactly the flood it exists to stop. The first scheduled runs after the edge
limiter shipped failed on 2026-09-01 with 96% 429s in both apps (ADC run 33500095682, Store run
33499906085), and ADC's load-freshness deploy gate keys off the last green run, so an unfixed
limiter would have blocked every ADC deploy from 2026-09-05. Raising PermitLimit for everyone or
adding the read paths to BypassPathPrefixes would have removed the protection from the very
surface it guards, and lifting the limit from the load-test workflow via a temporary container-app
revision would have made a workflow that promises to be read-only mutate production twice a month.
The framework instead gained a synthetic-traffic bypass (v1.180.0): a request carrying the
configured header (SyntheticTrafficHeaderName, default X-Synthetic-Traffic-Key,
GatewayRateLimitingSettings.cs:91) whose single value matches the configured secret
(SyntheticTrafficSecret, :115) takes the same no-limiter partition as the two tiers above on BOTH
chained limiters: IsSyntheticTraffic (GatewayRateLimitingExtensions.cs:108) feeds the one
IsExemptFromLimiters predicate (:182-185) that each partition consults (:210 and :248). It is
off by default (a null or blank secret disables it, :158-161), the comparison is constant-time
(CryptographicOperations.FixedTimeEquals, :172), exactly one header value is accepted (:164),
and a configured secret shorter than 32 characters fails at registration under the ADR-070 contract
([StringLength(int.MaxValue, MinimumLength = 32)], GatewayRateLimitingSettings.cs:114). The secret
is a deployment concern, never a checked-in setting: each consumer injects
GatewayRateLimiting__SyntheticTrafficSecret into its gateway from Key Vault the same way it injects
the SMTP password, and the k6 workflow sends the header from the matching repository secret. This
tier is for load and capacity proofs only; a monitoring probe belongs on the always-bypassed
infrastructure paths, and an application route that needs relief belongs in BypassPathPrefixes.
Amendment (2026-09-07): a fourth tier, the trusted internal caller. The synthetic-traffic tier
answers a load runner, and the 2026-09-07 security review found the same mechanism was needed for a
component the deployment owns. A server-rendered UI host makes every back-end call, token refresh
above all, from ONE container address on behalf of every signed-in visitor, so the per-IP window
collapses the whole site into a single partition and starts answering 429 as soon as the site is
busy: the limiter throttles the application rather than a caller. TrustedCallerSecret
(GatewayRateLimitingSettings.cs:151) with TrustedCallerHeaderName (default
X-Internal-Caller-Key, :123) generalizes the tier above from a load-test runner to any internal
caller the deployment trusts. The two share one implementation, so the guarantees are identical
rather than merely similar: IsTrustedInternalCaller (GatewayRateLimitingExtensions.cs:141) and
IsSyntheticTraffic (:108) both call PresentsSecret (:156), which is off when no secret is
configured (:158-161), rejects a multi-valued header (:164) and compares in constant time
(:172). The per-IP partition tests IsTrustedInternalCaller first (:202-208) and stamps
the request's HttpContext.Items under TrustedInternalCallerItemKey (:62, set at :206), so the
named per-route policies of MMCA.Common.Gateway exempt the trusted caller too (auth-tight
included; RateLimiting/GatewayRoutePolicyExtensions.cs:38, read at :99). Synthetic traffic, and
every request on the concurrency partition, go through the shared IsExemptFromLimiters predicate
(:182-185).
A configured secret shorter than 32 characters fails at registration
(GatewayRateLimitingSettings.cs:150). The secret is deployment data on both sides of the boundary:
each consumer injects GatewayRateLimiting__TrustedCallerSecret into gateway and UI container alike
from Key Vault (MMCA.ADC/infra/main.bicep:2404 and :2552, MMCA.Store/infra/main.bicep:1949 and
:2074), and the client half is framework code, AddTrustedCallerHeader
(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:106, attaching
TrustedCallerHandler, Security/TrustedCallerHandler.cs:35, at :141). It exempts a component you
deployed, never a browser, so it must never reach client-side code.
ADR-019 records the same tier from the limiter-policy side.
3. Readiness reflects the downstreams; liveness does not.
AddGatewayDownstreamHealthChecks(params string[] serviceNames)
(.../Gateway/GatewayHealthCheckExtensions.cs:130) registers one check per named downstream with an
HttpClient whose BaseAddress is the Aspire service-discovery name http://{name} (:194),
deduplicated through a registry so a repeated name cannot double-probe (:176-181, :219-272). Each
check probes /alive (DownstreamServiceHealthCheck.cs:46) under a 2 second budget
(GatewayHealthCheckExtensions.cs:84) applied at both the client (:195) and the registration
(:212), reports Unhealthy on failure (:210) and carries the Ready tag (:211).
The tag is the whole design. The Aspire defaults map /alive to checks tagged Live
(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.Health.cs:138-141) and /health/ready to
everything not tagged Live or Optional (:154-156), so a Ready-tagged downstream check reaches
readiness and never reaches liveness. Liveness must stay process-local, or a downstream outage restarts
a perfectly healthy Gateway and makes the outage worse. /alive answers "is this process wedged",
/health/ready answers "can this process do useful work", and only the second depends on anything
else. That is ADR-025's split, applied to a dependency rather than
to a startup task.
4. One call composes the YARP-level extension points (2026-08-27). AddMmcaGateway has two
overloads on IReverseProxyBuilder
(MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewayReverseProxyExtensions.cs:47 taking
IConfiguration, :68 taking a GatewaySettings instance), and both funnel into one Wire method
(:86) that registers, in order: the named per-route rate-limiter policies (:88), the cluster
profile config filter that owns each cluster's HttpRequest version policy (:91), the health-check
defaults filter (:92), and the trace-header transform (:93). Filter order carries no meaning and
the type says so: the two filters own disjoint parts of a cluster and neither reads what the other
wrote (:37-41). Settings bind from the "MmcaGateway" section (GatewaySettings.cs:15) through
ValidateDataAnnotations().ValidateOnStart() (GatewayReverseProxyExtensions.cs:54-57), the same
ADR-070 contract the rate-limit settings honor, and the per-route policies are additionally validated
at registration with Validator.ValidateObject
(RateLimiting/GatewayRoutePolicyExtensions.cs:62, reasoning at :45-46), because the limiter
factory closes over each policy object (:76), so a bad value would otherwise surface only at the
first throttled request.
It registers services and maps nothing, and it does not load the route table. The host still
calls MapReverseProxy() and UseRateLimiter() itself (GatewayReverseProxyExtensions.cs:42-45),
and LoadFromConfig stays the host's call because which section owns the routes, and whether they
come from configuration at all, is a host decision (:15-20). That keeps
ADR-089's "the route table is the consumer's data"
intact: this package supplies behavior around the table, never the table.
What the edge declines
Edge JWT pre-validation is deferred, and the deferral is the decision. The obvious next move is to validate the bearer token at the Gateway and reject an invalid one before it costs a forward. It is not being made. ADR-004 puts validation authority in the services, and a second validator does not add a check, it adds a second truth: two processes reading two JWKS caches can disagree across a key rotation, and the one that rejects is the one the caller sees. The Gateway would also acquire issuer and JWKS configuration it does not have today, making an Identity outage a Gateway outage, and it would have to decide what to do about ADR-022's cookie-carried browser sessions, which are not bearer tokens at all. The saving is one forward of a request the service was going to reject in microseconds anyway.
The trigger to revisit is measured rather than felt: when invalid-or-absent-token traffic becomes a material share of forwarded volume (visible in the edge limiter and downstream signals this record adds), pre-validation becomes a cost argument instead of a correctness argument and can be taken then, with the services still validating.
The limiter is in-memory, per replica. No Redis, no shared counter, and the type documents itself
that way (GatewayRateLimitingSettings.cs:10-21). With N Gateway replicas the effective ceiling is N
times PermitLimit, the same multiplication ADR-019 records for its own per-process limiters and only
partly retired there with its Redis option. It is accepted here rather than solved: the edge limiter
exists to bound a flood, not to meter a quota, and 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 kit adds no authorization, no path or body rewriting and no response shaping. Everything that depends on knowing who the caller is or what the payload means stays behind the proxy, which is what keeps the Gateway a transport concern and keeps ADR-008's extraction reversible.
Narrowed 2026-08-27. This originally read "no request rewriting", and the Gateway package now
adds exactly one request transform: GatewayTraceHeaderTransformProvider removes and re-adds
X-MMCA-Route and X-MMCA-Cluster on every proxied request
(.../MMCA.Common.Gateway/Transforms/GatewayTraceHeaderTransformProvider.cs:60-71, header names
defaulted at GatewaySettings.cs:181, :184). The exception is deliberate and narrow: it stamps
which route and cluster YARP selected, a fact only the proxy knows and one a downstream cannot
reconstruct, which is the same argument that made correlation an edge responsibility. It reads
nothing from the request and changes nothing a downstream parses. The decline that stands is the one
that matters: no path rewriting, no body rewriting, no response shaping, and nothing that depends on
the payload's meaning. ADR-089 anticipated this exact tension and left it open
(089-gateway-topology-owned-by-configuration.md, the "nothing prevents one from appearing" residual
in its Trade-offs); this is the answer, and the answer is one transform with a stated reason rather
than an open door.
What the edge delegates (2026-08-27)
Three behaviors a reader expects to find in a reverse proxy are absent on purpose, because a layer better placed to perform them already does. They are recorded here so an inventory of the gateway reads them as decisions rather than as omissions.
Bearer validation is delegated to the backends; the gateway forwards. This is the same decision
as the JWT decline above, stated from the delegation side, and the MMCA.Common.Gateway package
does not revisit it: nothing in it calls AddAuthentication, AddJwtBearer, AddAuthorization or
RequireAuthorization, and neither consumer gateway host registers an authentication scheme
(MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs,
MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs). The two hosts differ on authorization, and
the difference changes nothing about the caller: both route tables declare "AuthorizationPolicy": "anonymous" on every route, and only ADC evaluates the declaration. ADC registers the authorization
middleware pair (AddAuthorization at MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:112,
UseAuthorization at :218, the reasoning inline at :98-111 and :215-217) so that each route's
declared policy is read rather than implied, with the framework fallback policy deliberately not
adopted, because it ships in MMCA.Common.API and would pull the MVC stack into a pure YARP host
(:108-111). Store's routes declare anonymous as well, but its host registers no authorization
middleware, by design (MMCA.Store/Source/Hosts/MMCA.Store.Gateway/appsettings.json:45-49). With no
authentication scheme on either host, an evaluated anonymous policy admits every request, so both
gateways still forward every bearer untouched to the service that validates it. The Authorization header travels on
YARP's default request-header copy rather than through a transform of its own: the one transform the
package installs touches two headers and no others
(Transforms/GatewayTraceHeaderTransformProvider.cs:60-71), and neither gateway's
appsettings.json declares a Transforms block. Store states the posture in the host itself
(MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:12-13, "services validate JWTs themselves;
the gateway just forwards the Authorization header transparently") and again in its project file
(MMCA.Store.Gateway.csproj:6-7, "no JWT middleware"). The backends validate through JWKS discovery
against the authority (AddForwardedJwtBearer,
MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.Authentication.cs:51,
its AddJwtBearer at :81-82 inside AddForwardedJwtBearerCore (:76), Authority at :84), served by MapJwksEndpoint
(.../MMCA.Common.API/Startup/Endpoints/JwksEndpointExtensions.cs:31). Adding validation at the edge would
give the gateway issuer and key-discovery configuration it does not have, which is the coupling the
decline above rejects: an Identity outage would become a Gateway outage.
Load balancing is delegated to Azure Container Apps ingress. No LoadBalancingPolicy appears
anywhere in the framework, in either consumer gateway's configuration, or in either repository's
bicep. It is not needed, because every cluster fronts exactly one destination: an Aspire
service-discovery name (http://identity, http://conference) that ACA ingress resolves and
balances across the replicas behind it. ADC declares five clusters with one destination each
(MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:261-269, :270-278, :279-287,
:288-292, :293-297) and Store three (MMCA.Store/Source/Hosts/MMCA.Store.Gateway/appsettings.json:146-154,
:155-163, :164-168), resolved through AddServiceDiscoveryDestinationResolver
(ADC Program.cs:149, Store Program.cs:141) against the bicep address book
(MMCA.ADC/infra/main.bicep:2381-2384). The shape is not incidental: both repositories pin it as
an invariant, asserting that each cluster contains a single destination
(MMCA.ADC/Tests/Hosts/MMCA.ADC.Gateway.Tests/RouteMapTests.cs:251-253,
MMCA.Store/Tests/Hosts/MMCA.Store.Gateway.Tests/RouteMapTests.cs:315-317). A second destination in
a cluster would be the gateway balancing across replicas the platform is already balancing across,
with two schedulers holding different opinions about which instance is healthy.
Proxy-hop retries are delegated to client-side resilience. The gateway retries nothing: no retry
configuration, no Polly pipeline and no IForwarderHttpClientFactory appears in the package or in
either host. Retries live in the client the user is waiting on, where
EntityServiceBase runs a Polly exponential-backoff-with-jitter policy declared on its base
AuthenticatedServiceBase (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/AuthenticatedServiceBase.cs:25,
built at :140; executed in EntityServiceBase.cs at
:342 and :370), while the server-to-server budget is deliberately one retry beyond the initial attempt
(.../MMCA.Common.Shared/Resilience/HttpResilienceDefaults.cs:30, the up-to-16x storm argument at
:22-28). The decisive reason is ADR-017: the Idempotency-Key is
minted client-side and held constant across that client's own attempts, and it appears nowhere in
the gateway package or either gateway host. A proxy-hop retry would therefore be a replay with
nothing attached to make it safe, on a write the proxy cannot inspect to know whether replaying it
is harmless. Retrying where the key is is the only version that is correct.
Active destination probing is available and off by default, and both consumers leave it off. The
package can apply YARP health-check defaults to any cluster that declares none
(Configuration/GatewayHealthCheckDefaultsConfigFilter.cs, additive-only: an existing block is kept
verbatim, :30-31, class doc at :9-14). Passive checking is the default that is on
(GatewaySettings.cs:135, TransportFailureRate at :139, 60 second reactivation at :142),
because YARP watches the forwarded responses it is already making, so it costs no extra traffic
(:129-131). Active probing is opt-in (Enabled defaults to false, :153, reasoning at
:145-149: an extra probe per destination per interval is real traffic and real cost, and passive
checks already eject a destination failing the requests the gateway cares about); when enabled it
probes /alive (:170-171) on the ConsecutiveFailures policy (:156-157) every 10 seconds
(:160) under a 5 second budget (:163). /alive rather than /health is its own decision:
readiness on a downstream flips during that downstream's rolling deployment, and ejecting a
destination for that is the gateway reacting to a healthy deployment as if it were an outage
(:165-169). Both consumers set HealthCheckDefaults:Active:Enabled to false explicitly: every
cluster fronts one Container Apps address that the platform already routes only to ready replicas, so
an active probe could only eject the sole destination, and each probe bills an otherwise idle
downstream replica at the active rate (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:52-61,
MMCA.Store/Source/Hosts/MMCA.Store.Gateway/appsettings.json:26-35). Both pin the effective result
(MMCA.ADC/Tests/Hosts/MMCA.ADC.Gateway.Tests/GatewayHardeningTests.cs:85-89,
MMCA.Store/Tests/Hosts/MMCA.Store.Gateway.Tests/MmcaGatewayTests.cs:136-140), so the default is
off, the deployed answer is off, and passive checking stays on in both.
Rationale
- The edge is the only place that sees a request exactly once. That is what makes ensure-at-the-edge correct and mint-per-service wrong: not that the service version is broken, but that it runs after the point where uniqueness is free.
- The two rate-limit postures differ because the traffic differs, not because one is a mistake. ADR-019 measured its exemption against a backend fronted by an output cache and a Blazor Server circuit. The Gateway pays for anonymous traffic before either can help. Stating both postures together is what keeps the second from reading as a contradiction of the first.
- A chained concurrency limiter answers the failure a window cannot. A per-IP window is blind to ten thousand distinct addresses each behaving politely; a concurrency cap is blind to one address being rude but never lets in-flight work exceed what the process can carry. Neither alone bounds the Gateway's load.
- Bypasses are split by kind because they fail differently. A throttled health probe or JWKS fetch is an immediate self-inflicted outage, so it is not a setting. A throttled webhook or hub is application-specific and its correct list differs per app, so it is.
Readyand notLivekeeps a dependency failure from becoming a restart loop. Wiring downstream probes into liveness is the classic version of this mistake, and it turns a recoverable downstream blip into a fleet-wide restart at the worst moment.- Declining JWT validation is a real decision with a cost. It is recorded because the alternative is that someone reads the edge kit, notices the obvious missing piece, and adds it without knowing ADR-004 already placed the authority elsewhere.
Trade-offs
- The limiter closes over an eagerly-bound copy of the settings
(
GatewayRateLimitingExtensions.cs:278-279, consumed at:308and:310), so anIOptionsMonitorreload never reaches it. Validation is not the gap (both paths validate, see the Decision), but liveness of the value is: changing a limit is a restart, not a config push, which is the opposite of what "it is a configuration section" usually implies. The double validation is itself a consequence of that shape rather than belt-and-braces, so the two must stay in step: a future change that made the limiter resolveIOptionsper request would make the registration-time check redundant, and one that added a third construction path would need to route through the same overload to keep it. - Per-replica limits mean the configured number is not the enforced number. An operator reading
PermitLimit120 sees a per-replica figure; the fleet ceiling depends on how many Gateway replicas are running at that instant, so it rises exactly when the load that motivated it arrives. - Fail-open on an unknown IP is a hole with a name. A caller who can arrive without an attributable address is unlimited at the edge. The alternative (one shared bucket) is worse, and this is still a hole.
BypassPathPrefixesis a prefix match, so it is only as precise as the value given. A broad prefix exempts more than intended and nothing warns; the two entries named above are narrow, and a third added carelessly is an unlimited path through the only choke point.- Downstream health checks add fan-out and an all-or-nothing readiness. Every Gateway replica probes every named downstream on the health interval, and a slow-but-alive service can exceed the 2 second budget and mark the Gateway not-ready while it is still perfectly able to serve every other service's routes.
- Two correlation middlewares now exist with one header name written twice. The gateway type and the API type are separate, in separate packages, each declaring the literal. A rename in one is a silent break, the same duplicated-literal cost ADR-041 already records for the meter names in this same Aspire package.
- Two different
/aliveprobes now exist, and they answer different questions. The Aspire kit'sAddGatewayDownstreamHealthChecksprobes/aliveunder a 2 second budget and feeds the Gateway's own readiness, so a downstream outage takes the Gateway out of ACA's rotation. The Gateway package's active health check, when a host enables it (neither consumer does), probes the same path under a 5 second default and feeds YARP destination ejection, so a failing destination stops receiving forwards. Same path, same word "health", different mechanism and different consequence, and a reader who conflates them will misdiagnose the next incident. They are also tuned differently on purpose: the readiness probe is the tighter budget because it gates traffic to the whole process. AddMmcaGateway's configuration overload closes over an eagerly-bound copy too (GatewayReverseProxyExtensions.cs:59), so the per-route limiter policies never see anIOptionsMonitorreload, exactly as the Aspire kit's limiter does not. The two halves of the package differ here, which is worth knowing: the config filters resolveIOptions<GatewaySettings>per construction (Configuration/GatewayClusterProfileConfigFilter.cs:27,Configuration/GatewayHealthCheckDefaultsConfigFilter.cs:19), so a settings change reaches them at the next configuration reload while a limit change is still a restart.- The delegations are correct today because of facts nothing enforces framework-side. Load balancing is safely delegated only while each cluster has one destination, and proxy-hop retries are safely absent only while the idempotency key is minted client-side. Both consumers pin the first with a test; nothing pins the second beyond the fact that no gateway code mints a key. A future gateway that added a second destination to a cluster, or a retry, would invalidate a recorded decision without failing a build.
- Nothing gates adoption. A gateway that never calls the three registrations behaves exactly as
before, and no fitness function names a gateway host. Both consumer gateways do call all three
today (ADC
Program.cs:74,:88,:163; StoreProgram.cs:87,:112,:155), but that is a wiring habit rather than an enforced invariant, which is the audit-the-inventory caveat ADR-005 and ADR-017 both record, now applied to the edge.
Revision (2026-09-07)
Two changes from the 2026-09-07 security review.
- Health endpoints are cached server-side, and the bypass stays (SEC-Common-71 / SEC-ADC-17 /
SEC-ADC-56).
/healthand/health/readyare anonymous and rate-limit exempt by design, which is correct for a probe and wrong under a flood: each request ran every live dependency probe, and on a gateway it fanned out to every backend.CachedHealthReportProvider(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Health/CachedHealthReportProvider.cs:25) runs the probes at most once perHealthChecks:CacheSeconds(default 5,MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Health/HealthReportCacheOptions.cs:38, read atCachedHealthReportProvider.cs:59) with single flight through a per-entry semaphore (:39), so a flood costs one probe round per window instead of one per request. The exemption is kept rather than replaced: a throttled probe is an outage signal the orchestrator would act on./aliveis unchanged and still uncached, which preserves the rule that startup gates read liveness, never readiness. ADC's gateway caches its downstream readiness probes the same way (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/HealthChecks/DownstreamReadinessCache.cs:24,GetOrProbeAsyncat:78). - Edge-shaped controls are not gateway-only. This record assigned rate limiting and connection
bounds to the gateway, which left a public HTML origin fronted by a different ingress with
neither (SEC-Store-56). Store's storefront host now carries its own fixed-window limiter
(
UiRateLimitingSettings, on by default,MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Hardening/UiRateLimitingSettings.cs:31, 300 requests per 60 seconds at:54and:58, global concurrency 200 at:68; registered atMMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:107and applied at:239) and aBoundedCircuitHandler(MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Hardening/BoundedCircuitHandler.cs:37, registered atProgram.cs:99) that caps concurrent Blazor circuits atBlazorCircuitLimitSettings.MaxActiveCircuits(200,MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Hardening/BlazorCircuitLimitSettings.cs:39), withDisconnectedCircuitMaxRetained25 (:47) andDisconnectedCircuitRetentionSeconds60 (:55). A request limiter alone does not bound a server-rendered origin, because the expensive resource is the circuit a single page load opens, not the request that opened it.
Revision (2026-09-10)
Both public UI hosts now carry the own-host hardening, so item 2 above is no longer a Store-only remediation. ADC's conference UI is externally reachable on its own FQDN, which is the same condition that produced SEC-Store-56: a public HTML origin fronted by an ingress the gateway limiter never sees.
ADC ships the same trio under
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Hardening/: UiRateLimitingExtensions.cs:13 and
UiRateLimitingSettings.cs:33 for the per-IP fixed window chained with a replica concurrency
ceiling, BoundedCircuitHandler.cs:37 for the active-circuit cap, and
BlazorCircuitLimitSettings.cs:17 for its bounds. The limiter is registered at
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:127 and applied at :197.
One structural difference is worth naming so it is not read as drift. Store registers the circuit
handler directly (Program.cs:99), while ADC wraps registration and the CircuitOptions retention
callback in one AddBoundedBlazorCircuits() helper (Program.cs:62, the singleton at
Hardening/BlazorCircuitLimitExtensions.cs:58, the retention callback at :26-39, passed into
AddInteractiveServerComponents at Program.cs:53). The registered services are the same; only the
call shape differs.
The tuned numbers differ too, and that is the parameter doing its job rather than a divergence:
| Setting | ADC | Store |
|---|---|---|
PermitLimit per window |
1200 (UiRateLimitingSettings.cs:60) |
300 (UiRateLimitingSettings.cs:54) |
WindowSeconds |
60 (:64) |
60 (:58) |
GlobalConcurrencyLimit |
200 (:76) |
200 (:68) |
MaxActiveCircuits |
200 (BlazorCircuitLimitSettings.cs:40) |
200 (BlazorCircuitLimitSettings.cs:39) |
DisconnectedCircuitMaxRetained |
25 (:48) |
25 (:47) |
DisconnectedCircuitRetentionSeconds |
180 (:58) |
60 (:55) |
ADC's window is four times Store's because conference-day traffic arrives as a room full of attendees
behind a handful of shared NAT addresses, where a storefront's per-IP assumption of roughly one
shopper per address holds. Its 180-second disconnected retention is the ASP.NET Core framework
default kept rather than tightened, for the same reason: an attendee walking between rooms drops
Wi-Fi and expects the page to reconnect, and Store's 60 seconds trades that for memory it would rather
spend elsewhere. ADC restates both in configuration so the shipped value is visible without reading
the settings class (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/appsettings.json:19-24 and :28-32).
The shipped values are pinned by tests in the gating tier, so a silent retune fails a pull request:
MMCA.ADC/Tests/Hosts/MMCA.ADC.UI.Web.Tests/UiRateLimitingTests.cs:16 asserts burst shedding, exempt
paths, per-IP partitioning and the three limiter values, and BoundedCircuitHandlerTests.cs:16
asserts the ceiling, permit release and the three circuit bounds.
Still local, still not framework code. The Decision above records the deliberate reason Store declared this hardening in its own host rather than in MMCA.Common. Two near-identical copies now exist, which strengthens the case for extraction without settling it: a shared UI-host hardening kit would need its own decision, and this revision does not take one. That decision is taken in the 2026-09-20 revision below, which moves the kit into MMCA.Common and deletes both local copies.
Revision (2026-09-20)
The decision the last revision declined to take is taken here: the own-host UI hardening is
framework code. Two near-identical copies in two consumers is the shape that says a kit has stopped
being one app's remediation, and the second copy landed with no new thinking in it. The kit now ships
in MMCA.Common.UI.Web under the MMCA.Common.UI.Web.Hardening namespace, released in the framework
wave merged as 82036e7 on MMCA.Common main (MMCA.Common/CHANGELOG.md:24-36).
Five files carry it, all under
MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Hardening/:
| File | What it holds |
|---|---|
UiRateLimitingExtensions.cs:22 |
the per-IP fixed window chained with the replica concurrency ceiling, the exempt-prefix list (:42) and the exemption rule (:62) |
UiRateLimitingSettings.cs:33 |
the UiRateLimiting section (:36) and its three tuned values |
BlazorCircuitLimitExtensions.cs:19 |
the two registration halves, which attach to different builders |
BlazorCircuitLimitSettings.cs:17 |
the BlazorCircuitLimits section (:20) and its three bounds |
BoundedCircuitHandler.cs:39 |
the ceiling on concurrently ACTIVE circuits, counted on open (:58) and released on close (:79) |
Three entry points are the whole public surface: AddUiRateLimiting(configuration)
(UiRateLimitingExtensions.cs:145), UseUiRateLimiting() (:187), and the circuit pair
AddBoundedBlazorCircuits() (BlazorCircuitLimitExtensions.cs:51) with
BlazorCircuitLimitExtensions.RetentionFrom(configuration) (:28), the callback handed to
AddInteractiveServerComponents. The retention and the active-circuit ceiling stay two calls because
they attach to different builders, and they read the same section so the two numbers cannot drift.
Both consumers now consume the framework kit and their local copies are deleted. Neither change is
merged yet; both sit on the branch chore/common-wave-2026-09-20 in their repos.
- MMCA.ADC swaps one
using(MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:31) and keeps every call site where it was: the retention callback at:53,AddBoundedBlazorCircuits()at:62,AddUiRateLimiting(builder.Configuration)at:127andUseUiRateLimiting()at:197. Its wholeHardening/folder (five files) is gone. - MMCA.Store swaps the same
using(MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:24) and additionally loses an inline block: the hand-writtenCircuitOptionscallback and theAddOptions<BlazorCircuitLimitSettings>().BindConfiguration(...).ValidateDataAnnotations()plusAddSingleton<CircuitHandler, BoundedCircuitHandler>()pair becomeRetentionFrom(builder.Configuration)at:78andAddBoundedBlazorCircuits()at:86. The limiter is registered at:95and applied at:227. ItsHardening/folder (four files) is gone.
No configuration moved. The framework binds the same two sections (UiRateLimiting at
UiRateLimitingSettings.cs:36, BlazorCircuitLimits at BlazorCircuitLimitSettings.cs:20) and the
same six keys, which are the names both apps were already shipping, so neither appsettings.json
changed a character. That is what made the adoption one using per host.
The tuned values recorded in the 2026-09-10 revision still hold, and they are now set purely in each app's configuration against framework defaults rather than in a per-app settings class:
| Setting | Framework default | ADC | Store |
|---|---|---|---|
PermitLimit per window |
300 (UiRateLimitingSettings.cs:58) |
1200 (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/appsettings.json:21) |
300 (MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/appsettings.json:22) |
WindowSeconds |
60 (:62) |
60 (appsettings.json:22) |
60 (appsettings.json:23) |
GlobalConcurrencyLimit |
200 (:74) |
200 (appsettings.json:23) |
200 (appsettings.json:24) |
MaxActiveCircuits |
200 (BlazorCircuitLimitSettings.cs:38) |
200 (appsettings.json:29) |
200 (appsettings.json:36) |
DisconnectedCircuitMaxRetained |
25 (:46) |
25 (appsettings.json:30) |
25 (appsettings.json:37) |
DisconnectedCircuitRetentionSeconds |
60 (:57) |
180 (appsettings.json:31) |
60 (appsettings.json:38) |
The defaults are Store's numbers, which is deliberate: a public origin should ship limited even when a host configures nothing, and the tighter pair is the safe one to inherit. ADC's four-times-wider window and its 180-second retention keep the reasons the 2026-09-10 revision recorded (a venue full of attendees behind a handful of shared NAT addresses, and a walk between rooms that should reconnect), and both are now visible in configuration rather than in a settings class.
One behavior delta, and it is a widening. The framework exempts /hubs alongside /health,
/alive, /_framework and /_content (UiRateLimitingExtensions.cs:42), because a SignalR
connection is long-lived and its negotiate and reconnect traffic must never be throttled; that mirrors
the Gateway's own GatewayRateLimiting:BypassPathPrefixes. ADC's local copy already had the /hubs
prefix; Store's did not (four prefixes at MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Hardening/UiRateLimitingExtensions.cs:30
on main, before the deletion). Store's storefront origin serves no hub, so the exemption covers
nothing that exists there today and the observable behavior is unchanged; it is covered by declaration
rather than by accident if one ever appears. /_blazor keeps no exemption on either host, because the
negotiate endpoint is exactly what opens a circuit.
The unit facts moved with the code; the wiring facts stayed. The kit's own behavior is now tested
once, beside it, in MMCA.Common/Tests/Presentation/MMCA.Common.UI.Web.Tests/Hardening/:
UiRateLimitingTests.cs covers the exemption rule and the partition keys, and
BoundedCircuitHandlerTests.cs covers the ceiling, the rollback on refusal and the floor at zero.
What each consumer kept is the half only that repo can answer, which is whether its real host wires the
kit at all and on which numbers:
- MMCA.ADC:
MMCA.ADC/Tests/Hosts/MMCA.ADC.UI.Web.Tests/UiRateLimitingTests.cs:29proves the real host registers the limiter and the file still drives a burst through the limiter the host actually registered, so the conference-day window is pinned;BoundedCircuitHandlerTests.cs:25proves the handler is registered as a SINGLETON (a scoped registration counts to one per circuit and caps nothing) and:41pins the tightened retention values. - MMCA.Store:
MMCA.Store/Tests/Hosts/MMCA.Store.UI.Web.Tests/UiRateLimitingTests.cs:29andBoundedCircuitHandlerTests.cs:30keep the same two registration assertions and nothing else.
This closes item 2 of the 2026-09-10 revision as a remediation: the hardening is no longer a thing each
public UI host has to remember to write, and a third Blazor host gets it with one using and two
registrations.
Revision (2026-10-01)
Both consumers now turn active destination probing off. The framework half of the delegation is
unchanged: active checks stay opt-in (MMCA.Common/Source/Hosting/MMCA.Common.Gateway/GatewaySettings.cs:153)
and passive checks stay on. What changed is each consumer's choice. Both gateways set
MmcaGateway:HealthCheckDefaults:Active:Enabled to false
(MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:52-61,
MMCA.Store/Source/Hosts/MMCA.Store.Gateway/appsettings.json:26-35), for the reason the configuration
states inline: every cluster fronts one Container Apps address the platform already routes only to
ready replicas, so an active probe could only eject the sole destination (turning a slow request into
a 503), and each probe bills an otherwise idle downstream replica at the active rate. The tests now pin
the OFF answer (MMCA.ADC/Tests/Hosts/MMCA.ADC.Gateway.Tests/GatewayHardeningTests.cs:85-89,
MMCA.Store/Tests/Hosts/MMCA.Store.Gateway.Tests/MmcaGatewayTests.cs:136-140). The delegation
paragraph and the two-probes trade-off are corrected to match. Store's gateway host still describes
an active /alive probe every 30 seconds in its comments
(MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:40, :133); the configuration and its test
are what run.
Corrections that change no decision. The trusted internal caller now also bypasses the named
per-route policies: the per-IP partition stamps TrustedInternalCallerItemKey
(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Gateway/GatewayRateLimitingExtensions.cs:62, set at
:206) and auth-tight reads it
(MMCA.Common/Source/Hosting/MMCA.Common.Gateway/RateLimiting/GatewayRoutePolicyExtensions.cs:99).
The Gateway package carries one piece of host middleware, UseCommonForwardedHeaders()
(MMCA.Common/Source/Hosting/MMCA.Common.Gateway/ForwardedHeadersExtensions.cs:36), now recorded in
the Context. The server-to-server budget is one retry beyond the initial attempt, not one attempt
(MMCA.Common/Source/Core/MMCA.Common.Shared/Resilience/HttpResilienceDefaults.cs:30). Every other
citation in the current-state sections is refreshed against current line numbers (the rate-limiting
kit, the health mapping now in Extensions.Health.cs, the forwarded-JWT registration now in
WebApplicationBuilderExtensions.Authentication.cs, EntityServiceBase now under Services/Api/,
the route and cluster tables, both bicep files, the route-map tests and the package pins).
Two statements are flagged rather than changed. AddServiceDefaults attaches the standard Polly
resilience handler to every IHttpClientFactory client in both gateway hosts
(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:39, :49), so "no Polly pipeline
appears in either host" reads more broadly than the code. The comment above that registration says
the defaults also reach the YARP forwarder (:37-38), but no code in the gateway package or either
gateway host replaces YARP's forwarder client factory to route the proxy hop through
IHttpClientFactory, so this record does not adopt the comment's claim; whether the handler reaches
the proxy hop is left for review. ADC's route-table comment says the
framework fallback policy is registered and that an undeclared route fails closed
(MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:72-77), while the host registers plain
AddAuthorization() with no fallback (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:108-112);
this record follows Program.cs.
Superseded statements in earlier revisions. Those sections stay as written. ADC's app-side
DownstreamReadinessCache (2026-09-07 item 1) is deleted; the gateway relies on the framework
CachedHealthReportProvider with HealthChecks:CacheSeconds pinned to 10
(MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:40-42,
MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:90-96). The 2026-09-20 consumer changes are
merged, so neither consumer has a Hardening/ folder and every per-app Hardening/ anchor in the
2026-09-07 and 2026-09-10 revisions no longer resolves. The framework UI kit keeps /_blazor inside
the per-IP window but exempts it from the concurrency ceiling, because the same prefix carries the
circuit WebSocket
(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Hardening/UiRateLimitingExtensions.cs:140,
reasoning at :63-67), and it also exempts any path with a file extension (:80-81).
Related
ADR-008 (the record that made the Gateway the only entry point and gave it routing, CORS and auth forwarding; this is the first record to add cross-cutting behavior to it), ADR-019 (the three service-tier limiter layers this adds a fourth, edge-tier layer beside, and whose anonymous exemption the edge deliberately inverts), ADR-079 (the shared service pipeline the gateways sit outside of, which is what left these three behaviors unowned), ADR-041 (the correlation id extended one hop outward, and the duplicated-literal cost the second header-name declaration repeats), ADR-004 (the validation authority the edge declines to duplicate, and the JWKS discovery path the unconditional bypass protects), ADR-025 (the readiness model these downstream checks join, including why liveness stays process-local), ADR-070 (the fail-fast contract this kit's settings honor on both construction paths, the options pipeline and the registration-time check the closed-over copy requires), ADR-084 and ADR-039 (the two traffic shapes the configurable bypass list exists for), ADR-089 (the other half of this wave: what the Gateway routes, as opposed to what it does to a request on the way through).