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.
- A global limiter that only caps authenticated callers.
AddCommonRateLimiting(MMCA.Common.API) installs aGlobalLimiter(active on every request throughUseRateLimiter) 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-setapplication/grpccontent 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 atAnonymousHubPermitLimitfirst, 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 with429 Too Many Requests.
- Exempts infrastructure traffic outright (
- 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 themEventsCache/CategoriesCache/QuestionsCache/RoomsCache,MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:269-300), and login/registration brute-force is handled byLoginProtectionService(exponential-backoff account lockout afterMaxFailedAttemptsfailed logins, plus per-IP registration throttling). - A per-IP cap on the anonymous authentication endpoints, on by default.
AddCommonRateLimitingalso registers the named policyRateLimitPolicyAuthIp("auth-ip"): a fixed one-minute window keyed on the client IP,authIpPermitLimit(default 30) requests, overage rejected with429. Unlike the other named policies it is not left to each app to attach.AuthControllerBase(MMCA.Common.API) decoratesLoginAsyncandRegisterAsync, andPasswordResetAuthControllerBasedecorates theforgot-passwordandreset-passwordactions, andEmailConfirmationControllerBasedecorates the two anonymous email-confirmation actions,send-email-confirmationandconfirm-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 IdentityEmailConfirmationControllerderive 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):EnableRateLimitingAttributeleavesAttributeUsage.Inheritedat its default oftrue, and a derived override therefore still sees the base attribute throughGetCustomAttributes(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'sEndpointDataSourceand assertsPOST /Auth/registercarries theauth-ippolicy 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:RefreshAsyncis 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 getsNoLimiterrather 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. - The remaining named policies are opt-in, per-endpoint tightening.
AddCommonRateLimitingalso registersFixedPolicyandUserPolicy, 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. - 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-ipwindow, and the global limiter's third-choice fallback key) readHttpContext.Connection.RemoteIpAddress, whichUseForwardedHeadershas already rewritten fromX-Forwarded-Forearlier in the same shared pipeline (UseCommonMiddlewarePipelineruns forwarded headers beforeUseRateLimiter). That middleware is configured to trust any proxy: the pipeline clears bothKnownProxiesandKnownIPNetworks, 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-iplimiter, 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:
ForwardLimitis left at its default of 1, so only the rightmostX-Forwarded-Forentry 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 freshauth-ipbucket 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.
- 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) atMMCA.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]:GlobalPermitLimit300 (:38-40),AuthIpPermitLimit30 (:46-47),PerUserPermitLimit30 forUserPolicy(:34-36), andPermitLimit100 plusQueueLimit2 forFixedPolicy(:26-32). - A sliding window is selectable.
Algorithm(:53) takesRateLimitAlgorithm.FixedWindow(the default) orSlidingWindow(.../RateLimiting/RateLimitAlgorithm.cs:15,22), withSegmentsPerWindowdefaulting to 4 (:61-62,[Range(1, 60)], ignored underFixedWindow). 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. - The global and
UserPolicypartitions can be Redis-backed.Distributed(:72, defaultfalse) swaps inRedisFixedWindowRateLimiter(.../RateLimiting/RedisFixedWindowRateLimiter.cs:37), which keys onrl:{partitionKey}:{unixMinute}(:129-130), performs aStringIncrementAsync(: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"atMMCA.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.
- 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 ownMapGrpcServiceregistrations, so stampingContent-Type: application/grpcon 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). - Anonymous hub traffic is metered rather than exempt (SEC-ADC-25).
IsAnonymousHubRequest(WebApplicationBuilderExtensions.cs:111) matches the request path againstRateLimitingSettings.HubPathPrefixes(default["/hubs"],MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RateLimitingSettings.cs:91) and partitions those requests per client IP atAnonymousHubPermitLimit(default 60 per minute,:99, applied atWebApplicationBuilderExtensions.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. - A trusted internal caller can be exempted at the gateway.
GatewayRateLimitingSettings.TrustedCallerSecret(:151) andTrustedCallerHeaderName(defaultX-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. - ADC scopes its tighter gateway policy to the credential-submission routes. The gateway's
auth-tightpolicy (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:63) is attached toidentity-auth-login(:88-94, the policy at:93) andidentity-auth-register(:95-101, the policy at:100) and to nothing else, so/Auth/refreshkeeps 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.
- Store's gateway splits the credential routes out of the
/Authcatch-all. Itsauth-tightpolicy (MMCA.Store/Source/Hosts/MMCA.Store.Gateway/appsettings.json:37, the block at:36-43) is attached toidentity-auth-login(:78-84, the policy at:83) andidentity-auth-register(:85-91, the policy at:90), and the remainingidentity-authroute matching/Auth/{**catch-all}(:94-98) carries noRateLimiterPolicyat 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/Auththrottles 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. - 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 inMMCA.Common.UI.Web1.191.0 (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:87, in theextension(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 sameGatewayRateLimitingsection 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 declaresGatewayRateLimiting:TrustedCallerHeaderNameatMMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/appsettings.json:32-34and ADC's gateway declares the section atMMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:21-33(the header name at:32), with the secret injected asGatewayRateLimiting__TrustedCallerSecretby each template (MMCA.Store/infra/main.bicep:1889and:2006,MMCA.ADC/infra/main.bicep:2223and: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-ipdecoration belongs to the framework, not the apps. Decision item 3 said each app'sEmailConfirmationControllerapplied the policy to the two actions the framework bases did not own.EmailConfirmationControllerBasenow 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 anonymousNoLimiter:72),IsAnonymousHubRequest:82, the__infraNoLimiter:145, the global partition key chain ending in"authenticated":153-156,AuthIpRateLimitPartition:305(__unknown-ipNoLimiter:310,allowDistributed: false:318), andFixedPolicyallowDistributed: false:414. InRedisFixedWindowRateLimiter.cs:StringIncrementAsync:137, the 65-secondKeyExpireAsync:144, the fail-opencatch:149and itsInterlocked.Exchangewarn-once guard:151.MiddlewarePipelineBuilder.IsCleartextHttp2is defined atMMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineBuilder.cs:350and used at:86.GatewayRateLimitingSettings.cs:SectionName:51,TrustedCallerHeaderName:126,TrustedCallerSecret:154;GatewayRateLimitingExtensions.cs:FixedTimeEquals:172, wired at:148-149.AddTrustedCallerHeaderis atMMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:121, called fromMMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:136andMMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:129; Store's UI section is atMMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/appsettings.json:35-36. The trusted-caller secret is injected atMMCA.Store/infra/main.bicep:1959and:2084andMMCA.ADC/infra/main.bicep:2444and: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.
- The Redis partition key is namespaced per application (SEC-Common-53, shipped in MMCA.Common
v1.232.0). The framework builds
each
RedisFixedWindowRateLimiterwith 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 isCache:KeyPrefixwith its trailing colon trimmed, orApplicationNamespace.Resolvewhen no prefix is set (:199-205). That matches how the distributed cache resolves its own prefix: a configuredCache:KeyPrefixas is, else the application namespace plus:(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/CacheKeyPrefix.cs:73-88). The limiter still prefixesrl:and appends the Unix minute (MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RedisFixedWindowRateLimiter.cs:132, documented at:24-26), so the stored key isrl:{namespace}:{scope}:{partition}:{unixMinute}. By default, two MMCA applications pointed at one Redis therefore do not share a counter. Two hosts that set the sameCache:KeyPrefixorApplication:Namespaceshare 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 toapp(ApplicationNamespace.cs:34,:67). Therl:keyspace the Related section names still holds. - 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
AddPublicEndpointPolicycalls now run fromConferencePublicCache(MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:269) toBookmarkCountsCache(: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-ipNoLimiter:310inWebApplicationBuilderExtensions.RateLimiting.cs). - Anchors re-verified against current source:
AddTrustedCallerHeaderis called fromMMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:137(Store:129and the definition atMMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:121unchanged).CredentialRoutes_CarryTheTightRateLimiterPolicyis declared atMMCA.Store/Tests/Hosts/MMCA.Store.Gateway.Tests/MmcaGatewayTests.cs:144, with the per-route assertion loop at:155-161and the exact-setBeEquivalentToassertion at:163-168, still pinning both directions.RateLimiting__AuthIpPermitLimitis atMMCA.ADC/infra/main.bicep:1868(value'300'inside theconferenceModeblock, rationale at:1863-1865), and the ADCGatewayRateLimiting__TrustedCallerSecretinjections are at:2491and:2650(StoreMMCA.Store/infra/main.bicep:1959and:2084unchanged).
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.
Related
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).