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

Architecture Decision Record

ADR-019: Layered Rate Limiting with an Authenticated-Only Global Limiter

Status

Accepted. Revised 2026-08-01 (the auth-ip per-IP anonymous-authentication limiter, which the shared auth controller applies to login and register by default, is recorded as the third layer; the anonymous-surface trade-off was corrected to match; the edge trust posture behind every IP partition key was made explicit). Revised 2026-08-18 (the hard-coded limits become a bound RateLimitingSettings section, a sliding-window algorithm option joins the fixed window, and the global and UserPolicy partitions gain an optional Redis-backed distributed limiter with a fail-open posture, which partly retires the "in-process counters" trade-off below; auth-ip stays deliberately local. See the Revision (2026-08-18) at the end). Revised 2026-09-07 (the gRPC exemption keys on endpoint metadata and the negotiated protocol instead of a caller-set header; anonymous /hubs traffic is metered per IP; ADC's tighter gateway policy is scoped to the two credential-submission routes; and a trusted-internal-caller exemption generalizes the synthetic-traffic bypass). Revised 2026-09-19 (the Decision is brought in line with the two revisions below and with the current auth-ip surface: the gRPC exemption reads endpoint metadata rather than a content type, anonymous hub traffic is metered before the anonymous exemption applies, the second global partition key is the subject claim, and auth-ip now covers password reset and email confirmation alongside login and register). Revised 2026-09-30 (the global and UserPolicy partitions key on the subject claim first and fall back to identity name, because the name claim carries the non-unique full name, so two users with the same name shared one bucket; MMCA.Common v1.213.0). Revised 2026-09-10 (both gateways scope auth-tight to the two credential routes, and the trusted-caller client half ships in the framework as AddTrustedCallerHeader). Revised 2026-10-01 (the output-cache list reads as a sample, and the auth-ip no-IP rule no longer claims to mirror the global limiter). Revised 2026-10-06: every ADC service binds the RateLimiting section, and the email-confirmation auth-ip decoration is recorded as owned by the framework base rather than by each app. Revised 2026-10-07: the distributed limiter's Redis partition key is qualified by the application namespace (MMCA.Common v1.232.0), so by default two MMCA applications sharing one Redis no longer share counters. Revised 2026-10-19: Store's three services bind the RateLimiting section too, so both apps call the configuration overload in every service.

Context

Every service exposes read and write endpoints to the public internet through the gateway (ADR-008). Abusive or runaway clients (scrapers, credential stuffing, retry storms, a buggy SPA stuck in a loop) can exhaust a service's threads, database connections, and downstream quotas. ASP.NET Core ships a rate-limiting middleware, but "turn on rate limiting" is not the decision: the load-bearing questions are who gets limited, by what partition key, and what is exempt. A naive per-IP global limiter is actively wrong for this deployment for three reasons:

  • Public read endpoints are output-cached, so legitimate anonymous browsing should be cheap, not throttled.
  • Anonymous server-rendered (Blazor Server) traffic all shares the UI host's outbound IP, so an IP partition would throttle every public visitor as if they were a single abuser.
  • Login and registration brute-force is a distinct threat with a distinct control (account lockout and per-IP registration throttling), not a general request cap.

Decision

Rate limiting is layered, and the always-on global limiter is authenticated-only.

  1. A global limiter that only caps authenticated callers. AddCommonRateLimiting (MMCA.Common.API) installs a GlobalLimiter (active on every request through UseRateLimiter) that:
    • Exempts infrastructure traffic outright (NoLimiter): /health, /alive, JWKS / OIDC discovery (/.well-known/*), and gRPC inter-service calls, recognized from the routed endpoint's gRPC metadata rather than from the caller-set application/grpc content type (see the Revision (2026-09-07)). These are legitimately high-frequency.
    • Exempts anonymous traffic (NoLimiter) for the three reasons above, with one metered exception: an anonymous request to a configured hub path prefix is counted per client IP at AnonymousHubPermitLimit first, and only every other anonymous request falls through to the exemption (see the Revision (2026-09-07)).
    • Caps each authenticated caller to globalPermitLimit (default 300) requests per fixed one-minute window, partitioned by the subject (sub) claim, then identity name, then remote IP, rejecting overage with 429 Too Many Requests.
  2. Anonymous abuse is handled by the right-shaped control, not the global limiter. Public reads are served from the output cache (UseOutputCache; ADC's Conference service defines one public-endpoint policy per public aggregate, among them EventsCache / CategoriesCache / QuestionsCache / RoomsCache, MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:269-300), and login/registration brute-force is handled by LoginProtectionService (exponential-backoff account lockout after MaxFailedAttempts failed logins, plus per-IP registration throttling).
  3. A per-IP cap on the anonymous authentication endpoints, on by default. AddCommonRateLimiting also registers the named policy RateLimitPolicyAuthIp ("auth-ip"): a fixed one-minute window keyed on the client IP, authIpPermitLimit (default 30) requests, overage rejected with 429. Unlike the other named policies it is not left to each app to attach. AuthControllerBase (MMCA.Common.API) decorates LoginAsync and RegisterAsync, and PasswordResetAuthControllerBase decorates the forgot-password and reset-password actions, and EmailConfirmationControllerBase decorates the two anonymous email-confirmation actions, send-email-confirmation and confirm-email (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EmailConfirmationControllerBase.cs:81, :109), all six with [EnableRateLimiting(WebApplicationBuilderExtensions.RateLimitPolicyAuthIp)], so any consumer that inherits one of those bases gets them without opting in. Store's and ADC's Identity EmailConfirmationController derive from that base and add no attribute of their own (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/EmailConfirmationController.cs:33, MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/EmailConfirmationController.cs:32). What an override inherits, settled empirically (2026-08-13): EnableRateLimitingAttribute leaves AttributeUsage.Inherited at its default of true, and a derived override therefore still sees the base attribute through GetCustomAttributes(inherit: true), so a bare override very likely retains the policy rather than silently dropping it. The convention is unchanged regardless: apply the attribute explicitly on every override (ADC always did; Store now does), because endpoint metadata resolution is not something an app should have to reason about per action, and a dropped policy fails silently. Two guards pin it instead of leaving it to argument: a MMCA.Common reflection test that asserts both the base decoration and the inherited-on-an-override case, and a Store integration test that reads the booted host's EndpointDataSource and asserts POST /Auth/register carries the auth-ip policy in its metadata (the only check independent of the reflection question). It exists because the other two layers leave one hole between them: the global limiter no-ops for anonymous traffic outside the hub paths and the lockout is keyed per email, so a password spray (one password, many emails) from a single source was otherwise unthrottled. Three details are deliberate: RefreshAsync is not throttled (renewal is automatic and periodic, and Blazor Server circuits issue it server-side from the UI host's IP); a request with no attributable IP gets NoLimiter rather than sharing one bucket with every other such request (a deliberate difference from the global limiter, which puts an unattributable authenticated request in one shared "authenticated" bucket and an unattributable anonymous hub request in one shared "anonymous-hub" bucket, MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.RateLimiting.cs:66, :156, :309-310); and the default is 30 rather than a tighter 10 for the same shared-IP reason, since every Server-circuit user's login leaves from the UI host's address.
  4. The remaining named policies are opt-in, per-endpoint tightening. AddCommonRateLimiting also registers FixedPolicy and UserPolicy, which a specific action can apply with [EnableRateLimiting(...)] when it needs a tighter cap than the global default. Nothing in either app applies those two today.
  5. The client IP these partitions key on is whatever the edge forwards, and that is trusted from any proxy. Both IP-keyed decisions above (the auth-ip window, and the global limiter's third-choice fallback key) read HttpContext.Connection.RemoteIpAddress, which UseForwardedHeaders has already rewritten from X-Forwarded-For earlier in the same shared pipeline (UseCommonMiddlewarePipeline runs forwarded headers before UseRateLimiter). That middleware is configured to trust any proxy: the pipeline clears both KnownProxies and KnownIPNetworks, because cloud reverse proxies (Azure Container Apps, AWS ALB) front the services from internal addresses that are not in the default allow-lists, and an unlisted proxy means the headers are ignored and every request is attributed to the proxy instead. The decision is to take a caller-supplied IP as canonical rather than to run with no usable client IP at all.

Rationale

  • Limit the traffic that is both attributable and expensive. An authenticated request is tied to a principal and usually drives the database; capping per-principal stops a single account from monopolizing a service without punishing the public read path.
  • Do not punish shared-origin anonymous traffic. With Blazor Server fronting public browsing behind one IP, and public reads served from the output cache, an anonymous IP cap would throttle legitimate visitors at scale while barely protecting an already-cached backend.
  • Right control per threat. Brute-force is an auth concern answered by a per-email lockout plus a per-IP cap on the anonymous credential endpoints that carry it (login, register, password reset and email confirmation); general overload is a per-user request cap; infrastructure endpoints must never be throttled. A single global IP bucket conflates all three.

Trade-offs

  • The global limiter only protects the authenticated surface. The anonymous surface is covered endpoint by endpoint instead: the anonymous credential endpoints (login, register, password reset and email confirmation) carry the auth-ip limiter, with the login-protection service on top of it for login and register; anonymous hub paths are metered per IP by the global limiter itself; and public reads are served from the output cache. An uncached anonymous endpoint added later inherits none of that: it has no global cap and must opt into a named policy or its own control.
  • The per-IP cap keys on a header a caller can set. Trusting forwarded headers from any proxy is what makes the IP usable at all behind a cloud load balancer, but it makes a spoofable input the partition key of a security control. The exposure is bounded rather than absent: ForwardLimit is left at its default of 1, so only the rightmost X-Forwarded-For entry is consumed, and a proxy that appends the real client address wins for any traffic that actually traverses it. A caller that can reach a service without passing through that proxy can name its own IP and mint a fresh auth-ip bucket per request, leaving the per-email lockout (ADR-029, not IP-keyed) as the control still standing in front of a spray. Containing this is a deployment concern, not a limiter one: either the gateway is the only reachable ingress, or the allow-lists carry the real proxy ranges. Neither is enforced in code today.
  • Per-user partitioning depends on the authenticated principal being populated when the limiter partitions the request, so the limiter's placement relative to authentication in the request pipeline is load-bearing: move it and the partition sees a different (or empty) principal.
  • Defaults are deployment-agnostic. 300 requests/min/user is a coarse backstop, not a tuned SLO; a service with heavier legitimate per-user traffic must raise it, and a stricter endpoint must opt into a named policy.
  • In-process counters. Limiter state is per-instance, so across N replicas the effective ceiling is roughly N times the configured limit. This is an accepted backstop, not a distributed quota.

Revision (2026-08-18)

The layering above is unchanged: the global limiter is still authenticated-only, infrastructure and anonymous traffic are still exempt, auth-ip still covers login and register by default, and FixedPolicy / UserPolicy are still opt-in with nothing applying them. Three things below it changed.

  1. The limits are now a validated settings section. RateLimitingSettings (MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RateLimitingSettings.cs:21) binds the "RateLimiting" section (:24) at MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:307, falling back to a default instance when the section is absent, so an unconfigured host keeps exactly the behavior this record describes. Every figure the Decision quotes now has a named home and a [Range]: GlobalPermitLimit 300 (:38-40), AuthIpPermitLimit 30 (:46-47), PerUserPermitLimit 30 for UserPolicy (:34-36), and PermitLimit 100 plus QueueLimit 2 for FixedPolicy (:26-32).
  2. A sliding window is selectable. Algorithm (:53) takes RateLimitAlgorithm.FixedWindow (the default) or SlidingWindow (.../RateLimiting/RateLimitAlgorithm.cs:15,22), with SegmentsPerWindow defaulting to 4 (:61-62, [Range(1, 60)], ignored under FixedWindow). The window itself stays one minute under both algorithms (RateLimitAlgorithm.cs:5-6), so this is a smoothing choice, not a new cap: a fixed window lets a caller spend a full minute's budget in the last second of one window and again in the first second of the next, and the segmented window removes that doubling at the cost of holding per-segment state.
  3. The global and UserPolicy partitions can be Redis-backed. Distributed (:72, default false) swaps in RedisFixedWindowRateLimiter (.../RateLimiting/RedisFixedWindowRateLimiter.cs:37), which keys on rl:{partitionKey}:{unixMinute} (:129-130), performs a StringIncrementAsync (:135), and sets a 65-second TTL only on the increment that created the key (:137-143, the 5 seconds of slack being deliberate clock skew, :139-141), admitting the request when the returned count is within the permit limit (:145). Three partitions opt in: the global limiter (WebApplicationBuilderExtensions.cs:79-104, Redis scope "global" at :99), UserPolicy (:114-128, scope "user" at :123), and, since the 2026-09-07 revision below, the anonymous hub partition (AnonymousPartition, scope "hub" at MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:96).

auth-ip deliberately stays in-memory (allowDistributed: false, WebApplicationBuilderExtensions.cs:234, rationale at :143-147), as does FixedPolicy (:346-353). The per-IP cap on the anonymous authentication endpoints is a coarse brute-force backstop sitting in front of a control that is already global and stateful, the ADR-029 per-email lockout; making it distributed would put a Redis round trip on the login path to tighten a limit whose per-replica multiplication is already accounted for in its generous default of 30.

The distributed limiter fails open. Any Redis fault other than cancellation is caught and the lease is granted (:147-155), with a warning emitted at most once per window through an Interlocked.Exchange guard on a static field (:149-151, :44). That is the same posture the global limiter already takes for a request with no attributable IP: a rate limiter is a backstop, and a broken backstop must not become an outage. Two consequences are worth naming. The increment and the comparison are not transactional (:26-29), so genuinely concurrent requests can overshoot the limit slightly, which is accepted for a coarse cap. And setting Distributed = true in a host with no IConnectionMultiplexer registered silently degrades to the in-memory limiter rather than failing startup (:150-167, documented at RateLimitingSettings.cs:64-71), so this setting sits outside the ADR-070 fail-fast contract and a misconfiguration looks exactly like success.

The "In-process counters" trade-off above is therefore narrowed rather than removed: the effective ceiling is still roughly N times the configured limit across N replicas for auth-ip and FixedPolicy, and for the global and per-user partitions in any host that has not set Distributed or has no multiplexer. Every other trade-off in this record stands unchanged, including the forwarded-header trust posture, which the Redis partition key inherits verbatim.

Revision (2026-09-07)

The layering is unchanged: the global limiter is still authenticated-only, login and register still carry auth-ip, and infrastructure paths are still exempt. Four things below it moved, all from the 2026-09-07 security review.

  1. The gRPC exemption no longer trusts a request header (SEC-Common-44). IsRateLimitBypassed (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:75, applied at :171) decides on gRPC endpoint metadata, which routing produces from the server's own MapGrpcService registrations, so stamping Content-Type: application/grpc on an ordinary request no longer switches off the per-user cap. The HTTPS-redirect skip that shared the old test keys on the negotiated protocol instead (MiddlewarePipelineBuilder.IsCleartextHttp2, MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineBuilder.cs:362, branched at :98).
  2. Anonymous hub traffic is metered rather than exempt (SEC-ADC-25). IsAnonymousHubRequest (WebApplicationBuilderExtensions.cs:111) matches the request path against RateLimitingSettings.HubPathPrefixes (default ["/hubs"], MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RateLimitingSettings.cs:91) and partitions those requests per client IP at AnonymousHubPermitLimit (default 60 per minute, :99, applied at WebApplicationBuilderExtensions.cs:92-97). This is the one hole the "exempt anonymous traffic" rule left that no edge control reliably covers: a hub path is a long-lived upgrade an edge limiter is usually configured to pass through, and the traffic is anonymous until the connection is established.
  3. A trusted internal caller can be exempted at the gateway. GatewayRateLimitingSettings.TrustedCallerSecret (:151) and TrustedCallerHeaderName (default X-Internal-Caller-Key, :123) (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Gateway/GatewayRateLimitingSettings.cs) generalize the synthetic-traffic bypass that already sat beside them (:91, :115). It is off unless the secret is configured, the header is single-valued, and the comparison is constant time (CryptographicOperations.FixedTimeEquals, MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Gateway/GatewayRateLimitingExtensions.cs:162, wired at :138-139). It exists for the shared-origin problem this record already names: a server-rendered UI host's own back-end calls, token refresh above all, otherwise collapse into one client-IP partition and throttle every visitor together.
  4. ADC scopes its tighter gateway policy to the credential-submission routes. The gateway's auth-tight policy (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:63) is attached to identity-auth-login (:88-94, the policy at :93) and identity-auth-register (:95-101, the policy at :100) and to nothing else, so /Auth/refresh keeps the exemption this record requires for Blazor Server circuits, whose refreshes all leave from the UI host's address.

Revision (2026-09-10)

Both gateways now scope auth-tight the same way, and the trusted-caller exemption's client half ships in the framework. Two items, both landed.

  1. Store's gateway splits the credential routes out of the /Auth catch-all. Its auth-tight policy (MMCA.Store/Source/Hosts/MMCA.Store.Gateway/appsettings.json:37, the block at :36-43) is attached to identity-auth-login (:78-84, the policy at :83) and identity-auth-register (:85-91, the policy at :90), and the remaining identity-auth route matching /Auth/{**catch-all} (:94-98) carries no RateLimiterPolicy at all, so refresh, logout, forgot-and-reset and the OAuth callbacks sit on the edge global limiter alone. That is the same three-route shape ADC has, and it is what item 4 above requires of any Blazor Server host: a circuit's refreshes all leave from the UI host's single address, so a per-IP cap on /Auth throttles every visitor together. The pin moved with it: CredentialRoutes_CarryTheTightRateLimiterPolicy (MMCA.Store/Tests/Hosts/MMCA.Store.Gateway.Tests/MmcaGatewayTests.cs:150) asserts both directions, that every credential route carries the policy (:161-165) and that the set of routes carrying any policy is exactly that pair (:169-173), so re-attaching it to the catch-all fails the build.
  2. The trusted-caller client half is framework code, not per-app code. The header a UI host sends so its own back-end calls take the exempt partition is registered by AddTrustedCallerHeader, shipped in MMCA.Common.UI.Web 1.191.0 (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:87, in the extension(IServiceCollection services) block at :20). Both UI hosts call it and nothing else does (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:117, MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:141). The client reads the same GatewayRateLimiting section the gateway side already binds (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Gateway/GatewayRateLimitingSettings.cs:47), which is now the one section name in both repos: Store's UI host declares GatewayRateLimiting:TrustedCallerHeaderName at MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/appsettings.json:32-34 and ADC's gateway declares the section at MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:21-33 (the header name at :32), with the secret injected as GatewayRateLimiting__TrustedCallerSecret by each template (MMCA.Store/infra/main.bicep:1889 and :2006, MMCA.ADC/infra/main.bicep:2223 and :2363). One name on both sides of the boundary is the point: a client sending under one key while the gateway reads another fails open silently, as a plain throttled caller.

Revision (2026-10-01)

No decision or rationale changed. Two current-state statements in the Decision were corrected. The ADC Conference output-cache list now reads as a sample of a larger per-aggregate set (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:266-295) rather than as the complete set. The auth-ip no-IP rule (NoLimiter, MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.RateLimiting.cs:284-285) no longer claims to mirror the global limiter, because the global limiter does not fail open for unattributable traffic: an authenticated request with no subject, name or IP shares the "authenticated" bucket (:149-152) and an anonymous hub request with no IP shares the "anonymous-hub" bucket (:62); only anonymous non-hub traffic gets NoLimiter (:68), and that is the anonymous exemption, not a no-IP rule. The rate-limiting registration now lives in the partial WebApplicationBuilderExtensions.RateLimiting.cs, so the WebApplicationBuilderExtensions.cs anchors in the Revisions above are historical and are not rewritten.

Revision (2026-10-06)

Every ADC service binds the RateLimiting section. ADC's Identity and Notification services now call the configuration overload, AddCommonRateLimiting(builder.Configuration) (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:165, MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:144), so their declared limits bind through RateLimitingSettings instead of being ignored by the int overload. The Bicep key moved to the bound property name, RateLimiting__AuthIpPermitLimit (MMCA.ADC/infra/main.bicep:1836, rationale at :1831). The gateway auth-tight anchors in item 4 and in Revision (2026-09-10) point at the current lines.

  • The email-confirmation auth-ip decoration belongs to the framework, not the apps. Decision item 3 said each app's EmailConfirmationController applied the policy to the two actions the framework bases did not own. EmailConfirmationControllerBase now carries the attribute on both (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EmailConfirmationControllerBase.cs:81, :109) and the Store and ADC controllers are subclasses with no attribute of their own, so the Decision now counts six framework-decorated actions.
  • Current locations of facts recorded in the older Revisions (those sections keep their original anchors). In MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.RateLimiting.cs: IsRateLimitBypassed :46, AnonymousPartition :62 (the "anonymous-hub" key :66, the anonymous NoLimiter :72), IsAnonymousHubRequest :82, the __infra NoLimiter :145, the global partition key chain ending in "authenticated" :153-156, AuthIpRateLimitPartition :305 (__unknown-ip NoLimiter :310, allowDistributed: false :318), and FixedPolicy allowDistributed: false :414. In RedisFixedWindowRateLimiter.cs: StringIncrementAsync :137, the 65-second KeyExpireAsync :144, the fail-open catch :149 and its Interlocked.Exchange warn-once guard :151. MiddlewarePipelineBuilder.IsCleartextHttp2 is defined at MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineBuilder.cs:350 and used at :86. GatewayRateLimitingSettings.cs: SectionName :51, TrustedCallerHeaderName :126, TrustedCallerSecret :154; GatewayRateLimitingExtensions.cs: FixedTimeEquals :172, wired at :148-149. AddTrustedCallerHeader is at MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:121, called from MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:136 and MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:129; Store's UI section is at MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/appsettings.json:35-36. The trusted-caller secret is injected at MMCA.Store/infra/main.bicep:1959 and :2084 and MMCA.ADC/infra/main.bicep:2444 and :2602.
  • Anchors in the live sections (the Conference output-cache range and the unattributable-bucket anchors in Decision item 3) were re-verified against current source.

Revision (2026-10-07)

Re-verified against current source. The layering, every default, the auth-ip surface, the fail-open posture and both gateways' auth-tight scoping are unchanged. One storage detail of the distributed limiter moved, and the anchors that drifted since the 2026-10-06 audit are re-pointed.

  1. The Redis partition key is namespaced per application (SEC-Common-53, shipped in MMCA.Common v1.232.0). The framework builds each RedisFixedWindowRateLimiter with the partition key {namespace}:{scope}:{partition} (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.RateLimiting.cs:254, rationale at :247-249), where the namespace is Cache:KeyPrefix with its trailing colon trimmed, or ApplicationNamespace.Resolve when no prefix is set (:199-205). That matches how the distributed cache resolves its own prefix: a configured Cache:KeyPrefix as is, else the application namespace plus : (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/CacheKeyPrefix.cs:73-88). The limiter still prefixes rl: and appends the Unix minute (MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RedisFixedWindowRateLimiter.cs:132, documented at :24-26), so the stored key is rl:{namespace}:{scope}:{partition}:{unixMinute}. By default, two MMCA applications pointed at one Redis therefore do not share a counter. Two hosts that set the same Cache:KeyPrefix or Application:Namespace share a namespace on purpose (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/CacheKeyPrefix.cs:37-40, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Configuration/ApplicationNamespace.cs:22-25), and a host with no application name falls back to app (ApplicationNamespace.cs:34, :67). The rl: keyspace the Related section names still holds.
  2. The Conference output-cache range drifted after the 2026-10-06 audit. The range was correct when that audit re-verified it; MMCA.ADC #255 later moved it by two lines through comment edits higher in the file. The AddPublicEndpointPolicy calls now run from ConferencePublicCache (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:269) to BookmarkCountsCache (:300), and the four sampled names are all present (EventsCache :270, RoomsCache :278, CategoriesCache :279, QuestionsCache :280); Decision item 2 now cites :269-300. The unattributable-bucket anchors in Decision item 3 were and remain correct ("anonymous-hub" :66, "authenticated" :156, __unknown-ip NoLimiter :310 in WebApplicationBuilderExtensions.RateLimiting.cs).
  3. Anchors re-verified against current source: AddTrustedCallerHeader is called from MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:137 (Store :129 and the definition at MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:121 unchanged). CredentialRoutes_CarryTheTightRateLimiterPolicy is declared at MMCA.Store/Tests/Hosts/MMCA.Store.Gateway.Tests/MmcaGatewayTests.cs:144, with the per-route assertion loop at :155-161 and the exact-set BeEquivalentTo assertion at :163-168, still pinning both directions. RateLimiting__AuthIpPermitLimit is at MMCA.ADC/infra/main.bicep:1868 (value '300' inside the conferenceMode block, rationale at :1863-1865), and the ADC GatewayRateLimiting__TrustedCallerSecret injections are at :2491 and :2650 (Store MMCA.Store/infra/main.bicep:1959 and :2084 unchanged).

Revision (2026-10-19)

Store's services bind the RateLimiting section as well. Catalog, Sales and Identity now call AddCommonRateLimiting(builder.Configuration) (MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:141, MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:149, MMCA.Store/Source/Services/MMCA.Store.Identity.Service/Program.cs:136) in place of the parameterless int overload, matching ADC's four services (Conference :221, Engagement :151, Identity :165, Notification :144). Store declares no RateLimiting section in its service configuration today, so the bound settings fall back to their defaults and its limits are unchanged; what changes is that per-environment tuning and the distributed Redis limiter (Revision 2026-08-18) are reachable in Store by configuration alone.

ADR-004 (the JWKS/discovery traffic the limiter exempts, and the authenticated principal it keys on), ADR-008 (the gateway edge this protects), ADR-017 (request idempotency, the other inbound-edge safeguard against client retries), ADR-029 (the per-email lockout and registration throttle that sit on the login and register endpoints the auth-ip cap also covers, and the reason auth-ip stays local), ADR-026 (the Redis the distributed limiter reuses, and the IncrementAsync storage-format lesson the raw INCR here avoids by owning its own rl: keyspace, qualified per application), ADR-070 (the fail-fast configuration contract RateLimitingSettings binds into, and the Distributed degradation that sits outside it), ADR-079 (the shared middleware pipeline that places UseRateLimiter after authentication and after forwarded headers, which is what makes both partition keys resolvable).