Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

@@ -0,0 +1,101 @@
# @suaveplan/api-rate-limit — per-route / per-principal / per-tenant / per-IP token-bucket middleware for the @suaveplan/api plugin system. Implements the RequestRateLimiter contract consumed by @suaveplan/api-host's request pipeline and provides a Hono-shaped Middleware that returns 429 + Retry-After on denial. Backed by @suaveplan/rate-limiter's TokenBucket. In-memory store ships by default; the RateLimitStoreLike interface keeps Redis/distributed adapters pluggable.
Per-route / per-principal / per-tenant / per-IP token-bucket rate-limit
middleware for `@suaveplan/api`. Implements the
`RequestRateLimiter` contract consumed by `@suaveplan/api-host`'s
per-request pipeline (design doc §6 step 4a) and ships a richer
`check`-based middleware surface for adapter integrations that want the
429 response shape pre-computed.
Status: Stage-8 keystone (`0.1.0`). In-memory store ships by default;
the `RateLimitStoreLike` interface keeps a future Redis adapter
pluggable without changing call sites.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `RATELIMIT.*` error-code namespace for the `@suaveplan/api-rate-limit` package and the `RateLimitErr…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `RATELIMIT.*` error-code namespace for the `@suaveplan/api-rate-limit` package and the `RateLimitErr…
- [instruments](./src/instruments/instruments.md) — Owns the rate-limit OTel instrument registry. Today the package emits exactly one counter — `api.rate_limit.exceeded_to…
- [instruments](./src/instruments/instruments.md) — Owns the rate-limit OTel instrument registry. Today the package emits exactly one counter — `api.rate_limit.exceeded_to…
- [middleware](./src/middleware/middleware.md) — The package's centerpiece. `createRateLimitMiddleware` builds a per-request gate around the token-bucket store; the gat…
- [middleware](./src/middleware/middleware.md) — The package's centerpiece. `createRateLimitMiddleware` builds a per-request gate around the token-bucket store; the gat…
- [selector](./src/selector/selector.md) — Resolves the rate-limit bucket key for a per-request frame, given the route-declared (or host-defaulted) `RateLimitKeyS…
- [selector](./src/selector/selector.md) — Resolves the rate-limit bucket key for a per-request frame, given the route-declared (or host-defaulted) `RateLimitKeyS…
- [memory-store](./src/store/memory-store/memory-store.md) — Provides the default in-memory implementation of `RateLimitStoreLike`. Backed by `TokenBucket` from `@suaveplan/rate-li…
- [memory-store](./src/store/memory-store/memory-store.md) — Provides the default in-memory implementation of `RateLimitStoreLike`. Backed by `TokenBucket` from `@suaveplan/rate-li…
- [store-like](./src/store/store-like/store-like.md) — Declares the `RateLimitStoreLike` interface — the storage abstraction behind the per-route token-bucket middleware. Kee…
- [store-like](./src/store/store-like/store-like.md) — Declares the `RateLimitStoreLike` interface — the storage abstraction behind the per-route token-bucket middleware. Kee…
<!-- end:auto-docs -->
## Why this package exists
The host pipeline's `RequestRateLimiter.consume(key, weight)` slot is
synchronous and intentionally minimal. Plugins want richer policy —
`{ rpm, burst, key }` per route, plus a way for the host to declare
global defaults in `host.json`. This package owns both ends: the
token-bucket store, the key-selector resolution, the OTel counter
emission, and the projection back onto the host's narrow `consume`
shape.
## Modules
| Subpath | Surface |
|---|---|
| `@suaveplan/api-rate-limit` | Aggregate barrel — every public symbol. |
| `@suaveplan/api-rate-limit/middleware` | `createRateLimitMiddleware`, `RateLimitRouteOption`, `RateLimitDecision`, `HostRateLimitDefaults`, `RateLimitRouteOptionSchema`. |
| `@suaveplan/api-rate-limit/selector` | `RateLimitKeySelector`, `resolveRateLimitKey`, `RateLimitContext`. |
| `@suaveplan/api-rate-limit/store` | `RateLimitStoreLike`, `MemoryRateLimitStore`, `createMemoryRateLimitStore`, `RateLimitBudget`, `RateLimitTakeResult`. |
| `@suaveplan/api-rate-limit/instruments` | `createRateLimitInstruments`, `RATE_LIMIT_COUNTER_NAMES`. |
| `@suaveplan/api-rate-limit/error-codes` | `RATELIMIT_CODES`, `RateLimitError`, `RateLimitCode`. |
## Quick start
```ts
import { createRateLimitMiddleware } from "@suaveplan/api-rate-limit";
const mw = createRateLimitMiddleware({
hostDefaults: {
perTenant: { rpm: 600, burst: 30 },
perIp: { rpm: 60, burst: 10 },
},
});
const decision = mw.check({
plugin: "billing",
route: "POST /invoices",
option: { rpm: 60, burst: 5, key: "principal" },
ctx: requestFrame,
});
if (!decision.allowed) {
return new Response(null, { status: 429, headers: decision.headers });
}
```
## Wiring into `@suaveplan/api-host`
```ts
import { createRateLimitMiddleware } from "@suaveplan/api-rate-limit";
const mw = createRateLimitMiddleware({ /* opts */ });
const host = createHost({
rateLimit: mw.asRequestRateLimiter({
plugin: "billing",
route: "POST /invoices",
option: { rpm: 60, burst: 5 },
keyKind: "principal",
}),
// ...
});
```
## Acceptance
Pre-archive gate:
```
bunx --bun turbo lint typecheck test:coverage --filter=@suaveplan/api-rate-limit
```
100% statements/branches/functions/lines (Istanbul).

View File

@@ -0,0 +1,77 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable `RATELIMIT.*` error-code namespace for the
`@suaveplan/api-rate-limit` package and the `RateLimitError` subclass
of `SuaveplanError`. Every code shipped here is asserted (via the
`conformance` gate) to be thrown by at least one production
path in this package, so the namespace cannot drift away from the
implementation.
## Features
- `defineCodes("RATELIMIT", [...])` builds the registry through
`@suaveplan/error` so the global code dictionary picks the entries up
via the side-effect import in `src/index.ts`.
- `RateLimitError` extends `SuaveplanError<RateLimitCode>` and exposes
a public constructor so call sites can `throw new RateLimitError(...)`
outside the class declaration scope.
- Two codes ship in v1: `EXCEEDED` (the gate denied a request) and
`STORE_UNAVAILABLE` (the backing `RateLimitStoreLike` raised a
transport error such as a Redis disconnect on a future adapter).
- The `cause` chain and `context` map are preserved unchanged from
`SuaveplanError`, so OTel span-event adapters carry them through.
## Usage: Basic
```ts
import { RATELIMIT_CODES, RateLimitError } from "@suaveplan/api-rate-limit/error-codes";
throw new RateLimitError(
RATELIMIT_CODES.EXCEEDED,
"principal exceeded burst budget",
{ context: { key: "principal:alice", retryAfterMs: 1500 } }
);
```
## Usage: Advanced
Wrap a downstream store failure while preserving the cause chain — the
host's OTel span-event adapter walks `.cause` to attach both error
fingerprints to the failed-request span:
```ts
import { RATELIMIT_CODES, RateLimitError } from "@suaveplan/api-rate-limit/error-codes";
try {
await store.take(key, 1);
} catch (cause) {
throw new RateLimitError(
RATELIMIT_CODES.STORE_UNAVAILABLE,
"rate-limit store unreachable",
{ cause: cause as Error, context: { key } }
);
}
```
## API Reference
- `RATELIMIT_CODES` — frozen registry, keys: `EXCEEDED`,
`STORE_UNAVAILABLE`.
- `RateLimitCode` — union of the two code string literals.
- `RateLimitError` — extends `SuaveplanError<RateLimitCode>`; public
constructor `(code, message, options?)`.
## Implementation Notes
`defineCodes` runs once at module load. The package's `src/index.ts`
side-effect-imports this module so consumers of any subpath register
the codes into the global dictionary. The public constructor is
required by `scripts/lint/check-error-subclass-constructors.ts` and
matches the same shape used by every other `*-Error` subclass in the
project (e.g. `RouterInMemoryError`).

View File

@@ -0,0 +1,73 @@
# instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the rate-limit OTel instrument registry. Today the package emits
exactly one counter — `api.rate_limit.exceeded_total{plugin, route,
key_kind}` — which the design doc §12.4 lists as the canonical
rate-limit denial signal. The factory wraps the OTel global
meter-provider lookup so production gets real export when an SDK is
installed and tests get a no-op when one is not.
## Features
- `RATE_LIMIT_COUNTER_NAMES` frozen tuple — drives both factory
construction AND the conformance test that asserts each name is
present on the returned bundle.
- `createRateLimitInstruments({ meterProvider? })` — explicit override
precedence beats the global, so the middleware tests can assert
exact `.add` call counts against an injected fake provider.
- Returned object is `Object.freeze`-d so accidental mutation surfaces
immediately rather than silently breaking downstream label
invariants.
## Usage: Basic
```ts
import { createRateLimitInstruments } from "@suaveplan/api-rate-limit/instruments";
const instruments = createRateLimitInstruments();
instruments.counters["api.rate_limit.exceeded_total"].add(1, {
plugin: "billing",
route: "POST /invoices",
key_kind: "principal",
});
```
## Usage: Advanced
Wire a captured meter-provider to assert exactly-once denial
increments under a deterministic clock:
```ts
import type { MeterProvider } from "@opentelemetry/api";
import { createRateLimitInstruments } from "@suaveplan/api-rate-limit/instruments";
declare const fakeProvider: MeterProvider; // captures every .add() call
const instruments = createRateLimitInstruments({ meterProvider: fakeProvider });
// drive the middleware against the fake clock, then assert
// fakeProvider's recorded .add() invocations match the denial transitions
```
## API Reference
- `const RATE_LIMIT_COUNTER_NAMES: readonly ["api.rate_limit.exceeded_total"]`
- `type RateLimitCounterName = (typeof RATE_LIMIT_COUNTER_NAMES)[number]`
- `interface RateLimitInstruments { counters: Readonly<Record<RateLimitCounterName, Counter>> }`
- `interface RateLimitInstrumentsConfig { meterProvider?: MeterProvider }`
- `function createRateLimitInstruments(config?: RateLimitInstrumentsConfig): RateLimitInstruments`
## Implementation Notes
The package deliberately does NOT install an SDK — that's an operator
concern. When no SDK is registered, the OTel no-op meter-provider
returns no-op counters whose `.add()` is a function-call no-op, so
this module is hot-path safe even with default config. The host's
own `HostInstruments` already declares the same counter name; both
emit, both reach the same exporter — OTel aggregates correctly because
the meter name is the only difference and the SDK merges series by
name + label-set.

View File

@@ -0,0 +1,108 @@
# middleware
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The package's centerpiece. `createRateLimitMiddleware` builds a
per-request gate around the token-bucket store; the gate emits 429
responses with `Retry-After`, increments the
`api.rate_limit.exceeded_total` counter on every denied request, and
projects itself onto the host's `RequestRateLimiter` shape so the
synchronous `consume(key, weight)` call site in the host's request
pipeline gets the same backing store + instruments without
re-deriving the route policy.
## Features
- Route-declared budget `{ rpm, burst?, key? }` with Zod validation at
the ingestion boundary. `burst` defaults to `max(1, ceil(rpm/60))`
when omitted so a 1-rpm route still tolerates a single-request burst.
- Host-level defaults (`hostDefaults.perTenant`, `hostDefaults.perIp`)
applied when a route declares no `rateLimit`. Route-level
declarations always win.
- 429 response carries `Retry-After`, `X-RateLimit-Limit`,
`X-RateLimit-Remaining: 0`. Allowed responses carry `X-RateLimit-*`
hints without `Retry-After`.
- OTel counter `api.rate_limit.exceeded_total` increments once per
denied request, labelled `{ plugin, route, key_kind }`.
- `asRequestRateLimiter({ plugin, route, option?, keyKind? })` —
bridges into the host's `consume(key, weight) → boolean` contract so
the host pipeline keeps the same backing store / instruments.
- When no budget applies (no route option, no host default), the gate
is a no-op so plugins that never opt in pay zero overhead.
## Usage: Basic
```ts
import { createRateLimitMiddleware } from "@suaveplan/api-rate-limit";
const mw = createRateLimitMiddleware();
const decision = mw.check({
plugin: "billing",
route: "POST /invoices",
option: { rpm: 60, burst: 5 },
ctx: {
tenantId: "t1",
principal: { id: "alice" },
ip: "10.0.0.1",
request: new Request("https://h/invoices", { method: "POST" }),
},
});
if (!decision.allowed) {
// Return new Response(null, { status: 429, headers: decision.headers })
}
```
## Usage: Advanced
Inject a host-level default for IP-keyed buckets and a custom selector
on a specific route. Drive the bucket forward against a `FakeClock`:
```ts
import { createRateLimitMiddleware } from "@suaveplan/api-rate-limit";
import { createMemoryRateLimitStore } from "@suaveplan/api-rate-limit/store";
let now = 0;
const clock = () => now;
const store = createMemoryRateLimitStore({ clock });
const mw = createRateLimitMiddleware({
store,
clock,
hostDefaults: { perIp: { rpm: 30, burst: 3 } },
});
const limiter = mw.asRequestRateLimiter({
plugin: "billing",
route: "POST /invoices",
option: { rpm: 60, burst: 5 },
keyKind: "principal",
});
limiter.consume("principal:alice", 1); // → true, drains 1 token
```
## API Reference
- `interface RateLimitRouteOption { rpm: number; burst?: number; key?: RateLimitKeySelector }`
- `const RateLimitRouteOptionSchema` — Zod schema, runs at the ingestion boundary.
- `interface HostRateLimitDefaults { perTenant?: RateLimitBudget; perIp?: RateLimitBudget }`
- `interface CreateRateLimitMiddlewareOptions { store?; instruments?; clock?; hostDefaults? }`
- `interface RateLimitDecision { allowed; status; retryAfterSeconds; headers; key; keyKind }`
- `interface RequestRateLimiter { consume(key, weight): boolean }`
- `interface RateLimitMiddleware { check; asRequestRateLimiter; store; instruments }`
- `function createRateLimitMiddleware(options?: CreateRateLimitMiddlewareOptions): RateLimitMiddleware`
## Implementation Notes
The Zod schema runs once per route-option ingestion (cheap, not per
request) — the `check` hot path consults the parsed shape directly.
The decision returns a frozen header bag so downstream layers cannot
mutate it unintentionally. The `key_kind` attribute is bounded to the
four-value union from `ResolvedKey.kind` so OTel cardinality stays
bounded even for a custom selector. The `asRequestRateLimiter`
projection captures route policy at construction time because the
host's synchronous `consume(key, weight)` shape has no slot for it;
this keeps the host's per-request hot path single-load instead of
re-resolving the route policy on every call.

View File

@@ -0,0 +1,85 @@
# selector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Resolves the rate-limit bucket key for a per-request frame, given the
route-declared (or host-defaulted) `RateLimitKeySelector`. The
selector kind controls how the middleware fans the per-route budget
across distinct callers — `"tenant"` shares the bucket across every
principal in a tenant, `"principal"` isolates by authenticated user,
`"ip"` falls back to the network identity, and the `"custom"` form
gives the route author a free-form projection.
## Features
- Discriminated union `RateLimitKeySelector =
"tenant" | "principal" | "ip" | { kind: "custom"; select }`.
- Default selection (`selector === undefined`) prefers
`"principal"` when authenticated, else `"ip"` — matching design doc
§6 step 4a.
- Strict-mode fallback to the literal `"anon"` when the chosen kind's
source is `null` (no tenant, no principal, no IP). The middleware
never crashes; the limit just shares a single bucket across every
truly-anonymous caller.
- Returned `ResolvedKey` carries both the bucket key string AND the
`kind` label so the OTel counter can attach a stable
`key_kind` attribute without re-parsing the key.
## Usage: Basic
```ts
import { resolveRateLimitKey } from "@suaveplan/api-rate-limit/selector";
const { key, kind } = resolveRateLimitKey(
{ tenantId: "t1", principal: { id: "alice" }, ip: "10.0.0.1", request: new Request("https://h/") },
"principal"
);
// → { key: "principal:alice", kind: "principal" }
```
## Usage: Advanced
A custom selector that buckets per `(tenant, route-action-token)` so
two different mutations on the same route share fan-in but distinct
buckets:
```ts
import { resolveRateLimitKey, type RateLimitKeySelector } from "@suaveplan/api-rate-limit/selector";
const custom: RateLimitKeySelector = {
kind: "custom",
select: (ctx) => {
const u = new URL(ctx.request.url);
const action = u.searchParams.get("action") ?? "default";
return `${ctx.tenantId ?? "anon"}:${action}`;
},
};
const r = resolveRateLimitKey(
{ tenantId: "t1", principal: null, ip: "10.0.0.1", request: new Request("https://h/q?action=write") },
custom
);
// → { key: "t1:write", kind: "custom" }
```
## API Reference
- `type RateLimitKeySelector =
"tenant" | "principal" | "ip" |
{ readonly kind: "custom"; readonly select: (ctx: RateLimitContext) => string }`
- `interface RateLimitContext { tenantId: string | null; principal: { id: string } | null; ip: string | null; request: Request }`
- `interface ResolvedKey { readonly key: string; readonly kind: "tenant" | "principal" | "ip" | "custom" }`
- `function resolveRateLimitKey(ctx, selector?): ResolvedKey`
## Implementation Notes
The function is pure — it does not consult environment, the clock, or
the store. That makes it trivial to unit-test the default selector
chain against a structural `RateLimitContext` literal without
spinning up a full request pipeline. The custom selector kind is
deliberately the only variant that takes a callback; everything else
is data so the selector is plain JSON-serialisable for
`host.json`-level overrides.

View File

@@ -0,0 +1,88 @@
# memory-store
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the default in-memory implementation of `RateLimitStoreLike`.
Backed by `TokenBucket` from `@suaveplan/rate-limiter`, the store
provisions a bucket per unique key on first contact, refills it at
`budget.rpm / 60` tokens per second up to `budget.burst`, and evicts
idle buckets via a TTL sweep plus a hard `maxKeys` ceiling so an
adversarial caller cannot exhaust process memory by synthesising
unbounded keys.
## Features
- `clock: Clock` injection (defaults to `Date.now`) so tests can drive
the bucket forward deterministically against a `FakeClock`.
- TTL eviction — buckets untouched beyond `ttlMs` (default 10 min) are
removed by `sweep()`; the next `take` for that key provisions a
fresh bucket at full capacity.
- `maxKeys` cap (default 100_000) — when the cap is reached the next
`take` first calls `sweep()`, and if that does not free a slot
evicts the oldest entry.
- Per-key bucket identity is `(key, budget)` — changing the budget on
the same key resets the bucket so a route reconfigured at runtime
does not inherit a stale capacity.
- Invalid budgets (`rpm <= 0` or `burst <= 0`) throw
`RateLimitError(STORE_UNAVAILABLE)` rather than silently allowing
every request through.
## Usage: Basic
```ts
import { createMemoryRateLimitStore } from "@suaveplan/api-rate-limit/store";
const store = createMemoryRateLimitStore();
const r = store.take("principal:alice", { rpm: 60, burst: 5 }, 1);
if (!r.allowed) {
// 429 + Retry-After: Math.ceil(r.retryAfterMs / 1000)
}
```
## Usage: Advanced
Wire a deterministic `FakeClock` for tests, drive the bucket forward
manually, and sweep when the test asserts eviction:
```ts
import { createMemoryRateLimitStore } from "@suaveplan/api-rate-limit/store";
let now = 1_000;
const store = createMemoryRateLimitStore({
clock: () => now,
ttlMs: 60_000,
maxKeys: 10,
});
store.take("k", { rpm: 60, burst: 5 }, 1); // allowed
now += 120_000; // advance past ttlMs
store.sweep(now);
// the next take for "k" provisions a fresh bucket
```
## API Reference
- `class MemoryRateLimitStore implements RateLimitStoreLike`
- constructor `(options?: MemoryStoreOptions)`
- `take(key, budget, weight): RateLimitTakeResult`
- `evict(key): void` — idempotent
- `sweep(now?): void`
- `get size(): number`
- `interface MemoryStoreOptions { clock?: Clock; ttlMs?: number; maxKeys?: number }`
- `function createMemoryRateLimitStore(options?): MemoryRateLimitStore`
## Implementation Notes
`TokenBucket.consume` already performs lazy refill against the injected
clock, so the memory store does not duplicate refill arithmetic. The
`retryAfter` field returned by the bucket is in milliseconds; the
store forwards it verbatim as `retryAfterMs` (clamped to a minimum of
`1` so denied requests never report a sub-millisecond retry window).
Eviction is O(n) in the bucket count on the rare full-cap path; for
the documented `maxKeys` of 100k that is well under 1ms on any
realistic host. The Map iteration order is insertion-order per ES2015,
which makes the `evictOldest` traversal deterministic.

View File

@@ -0,0 +1,83 @@
# store-like
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Declares the `RateLimitStoreLike` interface — the storage abstraction
behind the per-route token-bucket middleware. Keeping the interface
type-only (zero runtime) makes it trivial to swap the default
in-memory store for a Redis-backed implementation in a later change
without ripping through call sites that already depend on the
`RateLimitStoreLike` shape.
## Features
- `RateLimitBudget` (`rpm`, `burst`) — the per-bucket policy passed
alongside every `take` call so the store can lazily provision a
fresh bucket on first contact.
- `RateLimitTakeResult` (`allowed`, `remaining`, `retryAfterMs`) — the
return shape the middleware translates into a 429 response with
`Retry-After` set to `Math.ceil(retryAfterMs / 1000)` on denial.
- `take(key, budget, weight)` — synchronous to match the pipeline
`RequestRateLimiter` contract. Async backends can wrap their I/O in
a request-scoped snapshot.
- `evict(key)` — idempotent eviction hook used by the TTL sweeper.
## Usage: Basic
```ts
import type { RateLimitStoreLike } from "@suaveplan/api-rate-limit/store";
declare const store: RateLimitStoreLike;
const result = store.take("principal:alice", { rpm: 60, burst: 5 }, 1);
if (!result.allowed) {
// 429 + Retry-After: Math.ceil(result.retryAfterMs / 1000)
}
```
## Usage: Advanced
A consumer-supplied Redis adapter satisfies the same interface — it
just wraps an inline Lua script in a synchronous wrapper around the
single-RTT redis-cluster call:
```ts
import type {
RateLimitBudget,
RateLimitStoreLike,
RateLimitTakeResult,
} from "@suaveplan/api-rate-limit/store";
class RedisStore implements RateLimitStoreLike {
take(key: string, budget: RateLimitBudget, weight: number): RateLimitTakeResult {
// Pre-fetched Lua reply lives on a request-scoped snapshot:
const reply = redisLuaTake(key, budget, weight);
return {
allowed: reply.allowed === 1,
remaining: reply.remaining,
retryAfterMs: reply.retryAfterMs,
};
}
evict(key: string): void {
redisDel(key);
}
}
```
## API Reference
- `interface RateLimitBudget { readonly rpm: number; readonly burst: number }`
- `interface RateLimitTakeResult { readonly allowed: boolean; readonly remaining: number; readonly retryAfterMs: number }`
- `interface RateLimitStoreLike { take(key, budget, weight): RateLimitTakeResult; evict(key): void }`
## Implementation Notes
The interface is intentionally minimal — no `peek`, no `reset`. Both
are derivable from `take(weight=0)` and `evict` respectively and would
just widen the surface every adapter has to maintain. The
`RateLimitTakeResult.retryAfterMs` field is measured in milliseconds
internally so adapter writers do not lose precision; the middleware
truncates to seconds when serialising the HTTP header.