# Osolix · API Rate-Limiting + Throttling Policy — v2

**Status:** Verified against code 2026-07-23 (decisions/0126 gap-closure follow-up) · v1 (2026-Q2)
contained several claims that didn't match the real rate-limiter configuration — this revision
corrects them against `Program.cs`.
**Owner:** Chief Architect + SRE Lead
**Audit dimensions:** #11 Security · #14 Performance · #18 Observability

This document defines how Osolix protects its API from abuse, runaway clients, and over-loaded
sub-processors.

## 1 · Named policies (as implemented in `Program.cs`)

| Policy name | Applies to | Partition key | Limit | Window |
|---|---|---|---|---|
| **Global fallback** (`opts.GlobalLimiter`) | Every endpoint without a more specific `[EnableRateLimiting]` attribute | tenant claim → user claim → IP | 600/min authenticated, 60/min anonymous | 1 min, config-overridable |
| `auth-strict` | Login, password reset, MFA/TOTP verify, step-up, token mint | IP + username hint (never the request body) | 5 | 1 min |
| `ai-copilot` | Invoice/asset-classifier copilots, AI settings | tenant claim → IP | 60 | 1 min |
| `admin-copilot` | Admin chat copilot | user claim → tenant claim → IP | 30 | 1 min |
| `public-ai-chat` | Anonymous public marketing concierge (`/api/marketing/chat`) | IP | 15 | 1 min (plus a process-wide daily budget circuit-breaker on top) |
| `erp-inbound` | ERP inbound webhooks, telemetry ingest, RFID gateway callbacks, visitor check-in, room-booking webhooks | tenant token in the URL → IP | 120 | 1 min |
| `occupant-portal` | Anonymous FM occupant fault-report kiosk | IP | 20 | 1 hour |
| `public-checkout` | Self-serve checkout, SAML endpoints, sandbox request intake | IP | 5 | 1 hour |
| Stripe webhook (`/api/webhooks/stripe`) | `[DisableRateLimiting]` — genuinely unlimited | n/a | n/a | n/a |

All limits are configuration-overridable (`RateLimit:*` keys) except the global fallback's window
name, which is fixed.

**Corrections from v1:** the old table listed "IoT telemetry ingest — 6000/token", "Bulk import —
6/user", "External Auditor read — 300/user", and "Privacy DSR submissions — 5/IP/day" as if each had
its own dedicated policy. None of the four exist as a distinct policy today — IoT/telemetry traffic
actually rides the `erp-inbound` policy (120/min, not 6000), and bulk import / external-auditor
reads / DSR submissions all fall through to the global default (600/min authenticated or 60/min
anonymous) with no dedicated stricter tier. The old table also claimed Stripe webhooks were
"unlimited" when the code carried no such exemption — that endpoint had no `[EnableRateLimiting]`
attribute at all, so with no tenant/user claim on an inbound webhook call it silently fell into the
60/min anonymous-IP global bucket and could have 429'd a legitimate Stripe retry burst. Both the
code and this doc are now fixed (`[DisableRateLimiting]` added to `StripeWebhookController`).

## 2 · Rejection response

* **429 Too Many Requests**, with a `Retry-After` header (seconds) when the limiter can compute one,
  and a JSON body: `{ "error": "rate-limited", "retryAfterSeconds": <n> }`.
* Every rejection is logged (Serilog `Warning`) with path, method, tenant/user claim if present, and
  IP, so a saturated bucket is visible for alerting.
* **Correction from v1:** the old doc additionally claimed `X-RateLimit-Limit` /
  `X-RateLimit-Remaining` / `X-RateLimit-Reset` headers are set on *every* response (not just
  rejections). That was never implemented — there is no per-partition remaining-count tracking
  wired up. Moved to §4 open items rather than left as a false "done" claim.

## 3 · Anti-abuse heuristics — status

None of the following are implemented today. They remain real, worthwhile ideas, not claims of
current behavior:

* Fingerprint unauthenticated traffic by IP + UA + TLS fingerprint.
* Block IPs that hit the anonymous cap repeatedly within a rolling window.
* CAPTCHA challenge on signup/login when fingerprint risk exceeds a threshold.
* Geo-block option per-tenant for regionally-restricted customers.

## 4 · Open items

* `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset` response headers — needs
  per-partition counter exposure the current `OnRejected` hook doesn't have.
* Per-tenant rate-limit override (e.g. Customer Success granting a temporary higher cap for a
  one-time data migration) — no `Tenant.RateLimitOverride`-style field or mechanism exists. Build
  only when a real customer need arises (none has, as of this revision) — see decisions/0126.
* Fingerprint-based abuse detection (§3).
* Per-tenant rate-limit-override UI (depends on the item above).
* Live rate-limit charts on the AI Governance dashboard.
* A dedicated, stricter policy for bulk-import and Privacy-DSR-submission endpoints — today both
  ride the global default, which is more permissive than the original (aspirational) design intent.
