Skip to content

feat(rate-limiting): publish the tier matrix and enforce it in CI (#614) - #627

Merged
robertocarlous merged 1 commit into
Neurowealth:mainfrom
johnsmccain:feat/rate-limit-tier-matrix
Oct 19, 2026
Merged

robertocarlous merged 1 commit into
Neurowealth:mainfrom
johnsmccain:feat/rate-limit-tier-matrix

Conversation

@johnsmccain

Copy link
Copy Markdown
Contributor

Closes #614

Context

The rate limits lived only in code. Every limiter was constructed inline in src/middleware/rateLimiter.ts and mounted by hand in src/index.ts or inside a route file, so:

  • "What is the budget on POST /api/v1/deposit?" needed an archaeology exercise across three files.
  • "Does it apply to a session or an API key?" had no answer at all — the principal and auth-type mapping lived in prose scattered across the limiter comments.
  • Nothing checked any of it. A route could gain or lose its limiter with no signal.

Proposal

  • A machine-readable tier catalogue: window, burst, principal, auth types, env vars, and the purpose of each tier.
  • A route → tier matrix, with the auth-type → tier mapping documented alongside it.
  • A CI check that walks the real route table and fails when the matrix and the code disagree.
  • The fail-open / fail-closed decision stated explicitly.

What is in the PR

File What it does
src/middleware/rateLimitTiers.ts The tier catalogue and the route → tier matrix, plus the resolver and the Markdown renderers the doc is generated from
src/middleware/rateLimiter.ts Every limiter now carries the tier it enforces; buildRateLimiter now requires one, so a limiter the matrix cannot describe is a compile error rather than a silent gap
src/routes/registry.ts The route table, extracted from src/index.ts so the check walks the routes the server actually serves instead of a hand-written list
src/middleware/rateLimiterChain.ts The app-wide global + tiered chain, named so the check can assert that no route is ever unlimited
docs/RATE_LIMITING.md The generated matrix, the auth-type mapping, the header contract, and the fail-open/fail-closed decision
tests/unit/middleware/rateLimitTierMatrix.test.ts The CI half — see below

The CI check

tests/unit/middleware/rateLimitTierMatrix.test.ts walks all 249 routes the server serves and fails when:

  • a mounted route resolves to no matrix entry (an undocumented budget);
  • the matrix says a route is in one tier while the code mounts a limiter from another (the table is lying);
  • a matrix entry no longer matches any real route (the table has rotted);
  • a limiter exists that the tier catalogue does not describe;
  • docs/RATE_LIMITING.md is not the table this module generates.

It caught two real drifts while this was being written: /api/v1/agent/decisions is a user-facing read (session auth) rather than a service-to-service endpoint, and the erasure limiter's metrics label read admin, making it indistinguishable from the general admin budget it is deliberately held far below.

Fail-open vs fail-closed

Runtime: fail-open. The app-wide chain (global + tiered) is mounted before routing, so a route missing from the matrix still gets a budget. No route is ever unlimited — "fail-open" here means "degrade to the generic budget", not "let the request through unchecked".

CI: fail-closed. The matrix is the contract, and the contract is enforced. The runtime behaviour is a safety net for the case where someone forgot; CI is where forgetting is caught.

Acceptance criteria

  • Matrix published in docs — docs/RATE_LIMITING.md, with both tables generated from the source of truth (npm run docs:rate-limits)
  • Untiered routes are a CI failure — the matrix check fails on any route with no entry
  • Which auth type maps to which tier is documented — the auth-type table in the doc, and authTypes on every tier in the catalogue

Testing

  • New: 18 tests in tests/unit/middleware/rateLimitTierMatrix.test.ts
  • Existing rate-limiter suites re-run and passing (44 tests across the four related suites)
  • tsc --noEmit and eslint clean
  • The 9 pre-existing unit-suite failures on main are unchanged by this PR (baseline on main: 9 failed suites / 64 failed tests; on this branch: 9 failed / 57 failed)

…urowealth#614)

The limits lived only in code: every limiter was constructed inline and
mounted by hand, so "what is the budget on POST /api/v1/deposit, and does
it apply to a session or an API key?" had no answer that was not an
archaeology exercise across three files — and no check that the answer
stayed true.

This adds the missing single source of truth and makes CI enforce it.

- src/middleware/rateLimitTiers.ts — the tier catalogue (window, burst,
  principal, auth types, env vars) and the route → tier matrix, plus the
  resolver and the Markdown renderers the doc is generated from.
- src/middleware/rateLimiter.ts — every limiter now carries the tier it
  enforces, and buildRateLimiter requires one, so a limiter the matrix
  cannot describe is a compile error rather than a silent gap.
- src/routes/registry.ts — the route table, extracted from src/index.ts so
  the check walks the routes the server actually serves instead of a
  hand-written list.
- src/middleware/rateLimiterChain.ts — the app-wide global + tiered
  chain, named so the check can assert that no route is ever unlimited.
- docs/RATE_LIMITING.md — the generated matrix, the auth-type mapping and
  the fail-open/fail-closed decision.
- tests/unit/middleware/rateLimitTierMatrix.test.ts — the CI half: an
  unmapped route, a matrix that disagrees with the mounted limiter, a
  matrix entry that no longer matches a real route, an undescribed
  limiter, or a stale doc each fail the build.

The fail-open / fail-closed split is deliberate and now stated in one
place. At runtime an unmapped route still gets the global + tiered
budgets, so it is never unlimited; in CI a gap is a hard failure, because
the matrix is the contract and the contract is enforced.

Fixes the erasure limiter's metrics label, which read "admin" and so was
indistinguishable from the general admin budget it is deliberately held
far below.

Closes Neurowealth#614
@drips-wave

drips-wave Bot commented Oct 19, 2026

Copy link
Copy Markdown

@johnsmccain Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@robertocarlous
robertocarlous merged commit 4919d80 into Neurowealth:main Oct 19, 2026
3 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Publish rate-limiter tier matrix and per-route budgets

2 participants