Edge, Rate Limits & Safety
Every figure on this page was measured on 2026-10-06 against the dev estate and the
deployed build main-7266d6495829, or read out of the source that was running at the time.
Where something could not be verified it says so instead of guessing.
There is no load balancer
Section titled “There is no load balancer”That is the accurate answer, not a gap in the research. Three independent measurements:
One container per service. docker ps filtered on the ai.logrite.service label returns
13 Logrite containers and 13 distinct service names — no service appears twice:
ai-service analyzer backend data-prepper demo-seeder devdocs downtime-trackerfrontend ingest-shim mcp-server semantic-search warden webhookNo replicas declared. Grepping the generated stack for replicas, deploy: or
mode: replicated across /opt/logrite-stack/*.yml returns one hit, and it is a comment
about OpenSearch shard replicas in docker-compose.health.yml — unrelated to service
scaling.
One origin per hostname. The tunnel’s own live configuration has 18 ingress rules, each pointing at exactly one loopback port. There is no upstream pool to balance across:
| hostname | origin |
|---|---|
dev-app.logrite.ai | http://127.0.0.1:3000 |
dev-leapfrogapi.logrite.ai | http://127.0.0.1:9800 |
dev-analyzer.logrite.ai | http://127.0.0.1:9001 |
dev-aiservice.logrite.ai | http://127.0.0.1:9300 |
dev-mcp.logrite.ai | http://127.0.0.1:9500 |
dev-warden.logrite.ai | http://127.0.0.1:9600 |
| … 12 more, same shape |
Read it from the tunnel rather than from a config file — the tunnel is token-managed, so the
ingress lives in the Cloudflare dashboard, but cloudflared logs the configuration it
fetched:
ssh dev 'journalctl -u cloudflared --no-pager \ | grep -o "Updated to new configuration.*" | tail -1'And nothing else is in the path. ss -ltnp shows nothing listening on :443 at all,
and the only listener on :80 is tailscaled — not a Logrite proxy. cloudflared runs as
a single process (pgrep -c cloudflared → 1), so there is not even tunnel-level redundancy
from a second replica.
What that means, and the evidence for it
Section titled “What that means, and the evidence for it”A single instance per service with no pool in front has one consequence worth stating plainly: a restart is a full outage for that service, not a drained instance. This is not a hypothetical — it was measured during this session’s own deploy. Immediately after the backend container was replaced, every API call returned:
HTTP 502 error code: 502Every probe in that window answered 502 with error code: 502 as the entire body — no
JSON envelope, nothing to branch on. The exact start was not timed, so no duration is claimed
here; what is measured is the recovery: a poll for swagger-ui.html returning 302 first
succeeded at 12:54:43 UTC, with the container reporting Up 53 seconds (healthy).
What the edge does provide
Section titled “What the edge does provide”Requests do transit Cloudflare — confirmed from the response headers on
dev-leapfrogapi.logrite.ai:
server: cloudflarecf-ray: a465022bab71c84f-MRSstrict-transport-security: max-age=31536000 ; includeSubDomainsalt-svc: h3=":443"; ma=86400cf-cache-status: DYNAMICSo TLS termination, HSTS for a year including subdomains, and HTTP/3 are in place at the
edge, and API responses are marked DYNAMIC (not cached).
Rate limits
Section titled “Rate limits”Three independent ceilings, one status
Section titled “Three independent ceilings, one status”The backend has three mechanisms that can answer 429, and they are not alternatives — each
covers the gap the others leave:
| counts | keyed on | state | answers | |
|---|---|---|---|---|
PublicRateLimitFilter | requests | source IP, or a credential digest | in-memory | 429 + Retry-After |
AuthRateLimitFilter | requests under /api/v1/auth/** — credential 20/15min, one-time-code 15/15min, email 10/hour, backstop 120/15min | source IP | in-memory | 429 + Retry-After |
AuthAttemptService / OtpGuard | wrong answers | the email address / the code | persisted | 429 + Retry-After |
The distinction that makes all three necessary: a correct password has no attempt ceiling,
so a credential-stuffing run spraying one guess each across ten thousand harvested addresses
never trips AuthAttemptService — every address sits at 1 of 5 — while still costing ten
thousand bcrypt verifications and ten thousand REQUIRES_NEW transaction writes. Conversely a
rate limit cannot stop a patient attacker making five guesses an hour at one account, which is
exactly what the attempt ceiling is for.
The guarded set is an allow-list
Section titled “The guarded set is an allow-list”RateLimitRules enumerates the guarded paths; shouldNotFilter returns true for everything
else. This is deliberate and load-bearing: the inverse design (meter everything, carve out
exceptions) puts the cost of a forgotten carve-out on a live platform. Paths that cannot
survive a request budget are therefore unreachable by any future edit to a limit —
/actuator/** (the container’s own liveness probe), /internal/** (service-to-service),
/api/v1/analytics/** (the OpenSearch proxy a single Analytics page load fans out across),
/api/v1/runs/** (scan-progress polling) and /ws/** (the progress socket) appear in no rule.
| group | budget | keyed per | paths |
|---|---|---|---|
EMAIL | 20/hour per IP and 10/hour per recipient | IP + recipient digest | /auth/signup, /auth/pre-register, /auth/resend-otp, /auth/forgot-password, /auth/mfa/send-otp, /user/password/** |
CREDENTIAL | 300/hour | IP | /auth/login, /auth/verify-otp, /auth/verify-magic, /auth/mfa/verify, /auth/mfa/passkey/**, /auth/reset-password/**, /auth/workspace, /user/password-reset, /workspace/invitations/** |
EXPENSIVE | 240/hour | credential digest | /analyzer, /generator, /code-analysis, /codeFixes, /scan/initiate |
CI | 600/hour | CI key digest | /gatekeeper/check |
Three of those choices are not the obvious ones, and each has a reason:
EXPENSIVEkeys on the bearer, not the IP. The ai-service forwards each end user’s own token from a single container address, so an IP key would pool every Cortex user on the platform into one bucket and starve them all for one caller’s retry loop.CIgets its own knob an order of magnitude higher. A429onPOST /gatekeeper/checkis a red build on a customer’s pull request, not a button they can click again. A whole organisation’s runners also share one egress address, and GitHub-hosted runners have no stable one — so it keys on the customer key presented as a bearer.EMAILhas a second ceiling on the recipient. The per-IP budget caps what one source can send; it cannot cap what one inbox receives, because the source is the attacker’s to vary. The damage — somebody else’s inbox, and this platform’s sending-domain reputation — is keyed on the recipient.
Every bucket key is stored as the hex of the first 16 bytes of SHA-256, never the raw value. A map of live session tokens or customer email addresses sitting in heap for the lifetime of the process is a credential store nobody asked for, and reachable from a heap dump; a 128-bit digest is just as unique a bucket and carries nothing back.
Measured live
Section titled “Measured live”The attempt ceiling. Six POST /api/v1/auth/login with a deliberately wrong password
against a non-existent address:
1:401 2:401 3:401 4:401 5:401 6:429{"message":"Too many failed attempts. Try again in 10 minute(s), or reset your password.","status":429}Five wrong answers, then a ten-minute lock — LOGIN_MAX_ATTEMPTS = 5,
security.auth-attempt.lock-minutes:10.
The rate ceiling, and its refill. 126 sequential GET /api/v1/auth/workspace?name=…
requests all returned 200 — which looks like a limiter that is not working and is in fact
the refill behaving as designed. PerIpRateLimiter refills continuously rather than
resetting on a window boundary, so at 300/hour it hands back 5 tokens a minute; the ~3
minutes those 126 sequential requests took refunded roughly 25 of them.
Outrunning the refill with a burst of 240 at 12 concurrent:
188 200 52 429and one of those refusals, with headers:
HTTP 429Retry-After: 12{"status":"FAIL","message":"Too many requests. Please try again in 12 second(s)."}Retry-After: 12 is arithmetic confirmation of which ceiling fired:
3_600_000 ms / 300 / 1000 = 12, the time for one token to accrue in the CREDENTIAL
bucket. The earlier 126 requests plus this burst crossed 300 in the hour.
Why a non-blocking limiter
Section titled “Why a non-blocking limiter”SimpleRateLimiter also exists in this codebase and is not used on inbound paths. It is a
single global counter that enforces itself with Thread.sleep — correct for pacing outbound
AI calls from a worker, and catastrophic on an inbound path, where a flood would park one
blocked Tomcat thread per request until the connector pool is gone. The limiter would become
the outage. PerIpRateLimiter never blocks, is keyed, refills continuously, and bounds its
own map.
That bound matters for the same reason: the key is attacker-influenced on the two IP-keyed
groups, so an unbounded map is the denial of service it exists to prevent. Eviction is
least-recently-used, capped at max-tracked-keys: 20000, and fail-open — an evicted
bucket allows the request rather than refusing it.
Per service, as actually configured
Section titled “Per service, as actually configured”No rate-limit environment variable is set on any dev container except ai-service’s
LOG_ANALYSIS_PUBLIC_RATE_LIMIT=60 (verified by reading env inside each running
container), so the defaults in code are what is running:
| service | inbound limiter | default | state on dev |
|---|---|---|---|
| backend | PublicRateLimitFilter + AuthRateLimitFilter + attempt ceilings | enabled | ON — proven by the 429 above |
| ai-service | per-JWT-subject sliding window; chat 60/min, global backstop 1200/min, maxKeys 20000 | RATE_LIMIT_DISABLED=false | ON |
| mcp-server | token bucket per group — mcp 600/60, skills_package 20/60, installer 60/60 | default ON (“an opt-in limiter is one nobody opted into”) | ON |
| analyzer | token bucket, allow-list — analyze 15000/60, llm 30/60, read 600/60, write 60/60 | default ON since 1fe7f3e0; only ANALYZER_RATE_LIMIT_ENABLED=false disables | ON |
| analyzer | admission control (bounded queue → 429 + Retry-After) | ANALYZE_MAX_INFLIGHT defaults to 0, and enabled is limit > 0 | OFF — unset, by design (see below) |
Safety properties
Section titled “Safety properties”Each of these is a property of the code that was running when this page was written, with the place it is enforced.
Startup fails closed on a weak signing secret
Section titled “Startup fails closed on a weak signing secret”JwtSecretGuard refuses to start on a blank, placeholder, or too-short
security.jwt.secret-key when security.jwt.require-strong-secret=true (the prod default).
The access, refresh, MFA and email-verification keys all derive from that one value, and it
once shipped as a committed placeholder — a token signed with it is forgeable by anyone
who has read the repository. dev and test set the flag false and print a warning to stderr
instead, which is why SECURITY_JWT_SECRET_KEY must be exported to run the suite locally.
Workspace scope comes from the token, never the request
Section titled “Workspace scope comes from the token, never the request”The tenant fence reads the workspace from the JWT workspaceId claim only — never a
request parameter or body field — via BearerWorkspaceScope.resolve. An absent claim
fails closed. There is an off-switch for installations still serving tokens minted before the
claim existed; it is a compatibility escape hatch, not the default.
Another tenant’s id is 404, with the same words as a missing row
Section titled “Another tenant’s id is 404, with the same words as a missing row”OwnershipService.denial() answers NOT_FOUND, and the reason string is the same literal
the absent-row branch throws — "project not found", "scan not found",
"workspace not found". Matching the status is not enough: a different message re-opens the
enumeration oracle in the body. The pre-fix 403 wording is retained verbatim so the
off-switch restores the old response exactly rather than approximately.
Roles are ranked, never compared for equality
Section titled “Roles are ranked, never compared for equality”RoleFloors.require resolves a floor and tests WorkspaceRoles.rank(role) >= required. An
unknown role ranks -1 and is therefore refused, not waved through. Equality checks are
what previously locked a workspace’s creator out of their own workspace when a higher role
was added above theirs.
CORS is an allow-list, mounted where it cannot be skipped
Section titled “CORS is an allow-list, mounted where it cannot be skipped”CorsConfig holds 12 explicit origins and no origin wildcard. (setAllowedHeaders("*")
is present and is a different thing — the origin list is what bounds who may read a
credentialed response, and allowCredentials with allowedOriginPatterns("*") is the
combination the file explicitly refuses, because it reflects whatever Origin is sent.) It is
a single global servlet
filter at Ordered.HIGHEST_PRECEDENCE, not a WebMvcConfigurer mapping — because the MVC
layer only runs once a request reaches a matched handler, so an auth failure on a
permit-all /api/v1/** route produced a response with no CORS headers, which a browser
reports as a CORS error rather than the 401 it is.
A 500 does not describe the database
Section titled “A 500 does not describe the database”The catch-all handler returns the exception type plus a generated reference
(err-xxxxxxxx) that is logged with the stack, and nothing else. It previously put
ex.getMessage() in details, and on a JDBC failure that message is the statement —
account deletion returned the table name, the violated foreign-key constraint and the
DELETE FROM to the caller.
A 202 is never the end of the story
Section titled “A 202 is never the end of the story”Any path that gives up before a scan starts must emit a terminal ERROR: <stage>: <reason>
frame and throw. A bare return or an NPE into the executor log is a scan that appears to
run forever. The corollary — that a refusal has to arrive on the channel the caller is
actually reading — is in
API Validation.
Deploys fail closed on data they cannot account for
Section titled “Deploys fail closed on data they cannot account for”ops/deploy/safety.py refuses to recreate a container whose data lives only in its writable
layer. It fails closed by construction: an unresolvable path, an unmeasurable layer and an
unknown container are all unsafe, and an unmeasurable size is returned as None rather
than 0, because 0 means “measured, empty” and treating an unmeasured container as empty is
how the guard fails open. A caller that never asked is a distinct NOT_ASKED sentinel, so a
legacy call site cannot inherit an “unknown ⇒ unsafe” verdict and start refusing everything.
The build gates that hold these in place
Section titled “The build gates that hold these in place”mvn -B test runs checkstyle through validate, so these fail locally and in CI on the
self-hosted logrite-dev runner: FileLength 300 (on src/main/java and
src/test/java), MethodLength 80, CyclomaticComplexity 15, ParameterNumber 8. The
suppression list holds zero entries and scripts/checkstyle-ratchet.sh fails the build
if it holds any — measured on this pass: base=0 current=0.
The one 429 that deliberately has no Retry-After
Section titled “The one 429 that deliberately has no Retry-After”OtpGuard.registerFailure destroys the one-time code when it reaches the ceiling rather than
merely refusing it — a lock that only has to be waited out costs an attacker time and nothing
else, so the code is burned and a new one must be sent to the real inbox. There is therefore no
interval after which that request succeeds, and a Retry-After would send a client back to an
endpoint that can only refuse it again.
This is why the header is attached by exception type and not by status: “every 429 gets a
Retry-After” would have produced a header that lies. A header that lies is worse than one that
is absent, because a client acts on it.
A fix can reach the wrong envelope
Section titled “A fix can reach the wrong envelope”Worth its own section, because the first attempt at the Retry-After fix passed every test and
changed nothing on the one path anybody probes.
Retry-After was added to GlobalApiExceptionHandler. Re-probing the deployed build, the login
lock answered exactly as before — 6:429 with no header. The body is what gave it away:
{"message":"Too many failed attempts. Try again in 10 minute(s)...","status":429}That is a {status, message} map, not the ApiError envelope. So this response never passed
through the global advice at all: AuthenticationController has its own
@ExceptionHandler(ResponseStatusException.class), and a controller-local handler wins over a
@ControllerAdvice. That is correct and deliberate — the auth screens read that shape — and it
is exactly why the fix missed.
The warning was already written down in the code the fix added, which is the uncomfortable part: a header set through a mechanism that handler never reads is a fix that tests green and ships nothing. The test asserted on the helper rather than on the envelope the controller returns, so it could not have caught it.
Two controllers held byte-identical copies of that handler. They share one
ApiErrors.statusEnvelope now, so a third auth controller copying the pattern gets the header
rather than silently not getting it.
What the measurement changed
Section titled “What the measurement changed”Two defects on this page were found by measuring rather than by reading, and both are closed. They are recorded here because a page that reports only the end state hides how it was found.
| what was measured | what it turned out to be | fixed in |
|---|---|---|
6 wrong logins → 429 with zero Retry-After headers, while a 240-request burst → 429 with Retry-After: 12 | 7 of the backend’s 8 429 sources named a wait only in English prose; one of the 8 correctly has no wait at all | 40677513 |
no rate-limit environment variable set on any dev container, and the analyzer’s limiter tested === 'true' | the analyzer’s limiter was not merely off on dev — it had never been active anywhere since it was written | 1fe7f3e0 |
a 900-request burst with a malformed body (a broken probe: xargs -I{} substituted the sequence number into -d '{}') → 900 × 500 | the analyzer answered 500 for a client’s own unparseable body, so the backend’s AnalyzerRetry re-sent a request that could never succeed | 1f7378a3 |
Verified on the deployed build after each fix:
analyzer, limiter with NO environment variable set GET /analysis-types 200 x-ratelimit-group: read x-ratelimit-limit: 600 900 x POST /rules/validate 237 x 400, 663 x 429 the refusal 429 retry-after: 1 {"error":"rate_limited","group":"write","limit":60,"retry_after_ms":271}
analyzer, malformed body (was 500) POST /rules/validate -d '899' 400 {"error":"invalid_request_body", ...} POST /analyze -d '{"files":' 400 POST /rules/validate -d '{}' 400 {"error":"rule.ruleId is mandatory"} <- unchanged
backend, the rate ceiling 25 x POST /auth/login 429 at request 16 retry-after: 45retry-after: 45 is 900_000 ms / 20 / 1000 — the credential bucket’s own refill rate, not a
constant, which is what proves the header is derived from the bucket that refused.
The second is the one worth generalising. A guard whose activation depends on someone remembering an environment variable is a guard that is off, and the evidence was not subtle: not one container set one. The analyzer had borrowed its default-off stance from a rule about enterprise-scale capabilities, and the distinction it missed is that each of those changes what the service computes — so defaulting one on changes output nobody asked to change — whereas a rate limit changes no output at all. mcp-server’s limiter in this same estate had already reached that conclusion in its own words: an opt-in limiter is one nobody opted into.
Default-on was only safe to do because analyze’s capacity had already been measured against a
real scan’s fan-out. At the old 120/60s a 3,600-request fan-out took 2,294 429s — 64% of
one scan — so turning the guard on then would have broken the product, which is worse than
leaving it off. At 15,000 it is 2x the measured physical per-worker ceiling and a scan cannot
reach it.
Scope it honestly: the analyzer surface is token-gated (401 without X-Analyzer-Token), so
what was unmetered was an authenticated or leaked-token caller, not the open internet.
Known gaps
Section titled “Known gaps”Stated because a page that only lists what works is not an accurate page.
- No load balancer and no horizontal scaling. One container per service, so a restart is a
full outage for that service rather than a drained instance. No duration is claimed: the start
of the window was never timed, only the recovery (first
302at 12:54:43 UTC). An earlier version of this page said “~90 seconds”; that figure was never measured and has been removed. - The analyzer’s admission control is still off, and that one is deliberate.
ANALYZE_MAX_INFLIGHTis named in the analyzer’s own standing rule — “enterprise-scale capabilities are opt-in and default-off, so an unchanged deployment runs the path the golden corpus covers” — so it stays a config change rather than a code change. The analyzer’s rate limiter was in the same state and was not covered by that rule, and is fixed: see What the measurement changed. - Cloudflare-level WAF or rate rules are unverified — not readable from here.
- Prod topology is unverified — no SSH access during this pass.