/** * Rate-limit primitives — framework-level containment for "too many of this * intent kind from this caller in this window." * * The kernel `adjudicate()` is synchronous and pure. Counter I/O (Redis * INCR) is inherently async, so this module mirrors the framework's * existing idiom for ledger-shaped concerns: I/O lives outside the kernel, * and the kernel sees a pre-resolved value via state. * * Primitives: * * 1. `RateLimitStore` — async interface adopters wire to Redis (or any * atomic-counter substrate). * * 2. `checkRateLimit(args)` — adopter calls this in their executor layer * *before* `adjudicate()`, attaches the result to state. * * 3. `createRateLimitGuard({ resolveCount, max, onExceeded })` — synchronous * `Guard` that reads the resolved count and emits an adopter-chosen * Decision (REFUSE / ESCALATE / RUF) when the cap is exceeded. * * 4. `createInMemoryRateLimitStore()` — reference single-process store for * tests and adopters that don't need cross-instance coordination. * * Adopters needing in-process rate limiting can short-cut: call * `checkRateLimit` inline from a synchronous wrapper (using an in-memory * store) and feed the count straight to the guard. */ import { type Decision } from "../decision.js"; import type { AggregateSnapshot, IntentEnvelope } from "../envelope.js"; import type { Guard } from "./policy.js"; export interface RateLimitStore { /** * Atomically increment the counter at `key` and return the new value. * On first call within a fresh window, starts at 1. Implementations MUST * scope the counter to a window of `windowMs` — typically via INCR + EXPIRE * NX in Redis. Cross-instance correctness is the implementation's * responsibility. */ incrementAndGet(key: string, windowMs: number): Promise; /** * T5 (#41 / top-priority E): roll back a previous increment. Used when * the kernel decides REFUSE/ESCALATE/DEFER (anything other than * EXECUTE) so that the rate-limit counter does not advance for * requests that were never authorized. Hostile traffic flooding a * session with invalid requests would otherwise exhaust legitimate * users' budgets. * * Optional for back-compat — a store that does not expose `decrement` * cannot roll back, and the rollback hook in `RateLimitResult` becomes * a no-op. */ decrement?(key: string): Promise; } export interface CheckRateLimitArgs { readonly store: RateLimitStore; readonly key: string; readonly windowMs: number; readonly max: number; } export interface RateLimitResult { readonly count: number; readonly exceeded: boolean; readonly max: number; /** * T5: invoke after the kernel returns a non-EXECUTE Decision so the * counter does not advance for unauthorized requests. No-op when the * store does not implement `decrement`. Idempotent — safe to call * exactly once per `checkRateLimit` even on EXECUTE; the framework's * usage in `adjudicateAndAudit` calls it only on non-EXECUTE. */ readonly rollback: () => Promise; } /** * Increment the counter at `key` and report whether the cap was exceeded. * Adopters call this in their executor before `adjudicate()` and stash the * result on state for the guard to consume. * * The returned `rollback()` reverses the increment when the kernel * Decision turns out to be non-EXECUTE — the load-bearing T5 fix for * hostile-input rate-limit poisoning. */ export declare function checkRateLimit(args: CheckRateLimitArgs): Promise; export interface RateLimitGuardOptions { /** * Read the count for this envelope+state. Typically returns a number that * was previously stashed by the executor after calling `checkRateLimit`. * Returning `undefined` skips the check (the guard returns null). */ readonly resolveCount: (envelope: IntentEnvelope, state: S) => number | undefined; readonly max: number; /** * Decision factory called when count exceeds max. Adopters typically * return REFUSE; some return ESCALATE for high-trust paths. */ readonly onExceeded?: (count: number, max: number) => Decision; } /** * Build a synchronous Guard usable in any `policy.business[]`. Reads a * pre-resolved count and emits the configured Decision when the cap is * exceeded; otherwise returns null and lets adjudication continue. */ export declare function createRateLimitGuard(options: RateLimitGuardOptions): Guard; /** * Single-process rate-limit store. Tracks counters in a Map with per-entry * TTL. Suitable for tests, single-instance deployments, and adopters that * only need session-scoped rate limiting. Does NOT survive process restart; * does NOT coordinate across instances. Use a Redis-backed store for that. */ export declare function createInMemoryRateLimitStore(now?: () => number): RateLimitStore; /** One configured horizon: which window in the snapshot, and its cap. */ export interface VelocityHorizon { /** * Key into `AggregateSnapshot.windows`. Opaque adopter string identifying a * (resource, horizon) view — e.g. `"acct_7|daily"`. A key absent from the * snapshot is treated as a committed count of 0 (no traffic recorded yet). */ readonly windowKey: string; /** * Inclusive cap for this horizon. The guard fires when * `committed + increment > max` (the cap value itself is allowed — strict * greater-than, identical to `checkRateLimit`'s `count > max`). */ readonly max: number; } /** Details of the first horizon that breached, passed to `onExceeded`. */ export interface VelocityBreach { readonly windowKey: string; /** The already-committed aggregate read from the snapshot for this window. */ readonly committed: number; /** The projected contribution of THIS decision (default 1). */ readonly increment: number; /** The configured cap for this window. */ readonly max: number; /** `committed + increment` — the projected post-decision count. */ readonly projected: number; } export interface CumulativeVelocityGuardOptions { /** * Read the injected `AggregateSnapshot` (the 052 multi-horizon counter view) * for this envelope+state. Returning `undefined` skips the check (the guard * returns null) — e.g. when the shell did not inject a snapshot for this kind. */ readonly resolveSnapshot: (envelope: IntentEnvelope, state: S) => AggregateSnapshot | undefined; /** * The horizons this guard enforces. ALL are checked; the FIRST (in array * order) that breaches drives the emitted Decision. Order is the adopter's * declared precedence — evaluation is deterministic and does not depend on * snapshot iteration order. */ readonly horizons: ReadonlyArray; /** * The count this decision would add to each window if it executed. Defaults * to 1 (one intent of this kind). Pure — derived from the envelope/state * only; MUST be deterministic (no clock/RNG). A non-finite or negative value * is clamped to 0 so a malformed resolver cannot fabricate headroom. */ readonly resolveIncrement?: (envelope: IntentEnvelope, state: S) => number; /** * Decision factory called with the first breaching horizon. Adopters * typically return REFUSE; some return ESCALATE/DEFER for higher-trust * paths. §C: it MUST raise friction — the lint/monotonic-ceiling invariants * forbid returning EXECUTE. */ readonly onExceeded?: (breach: VelocityBreach) => Decision; } /** * Build a synchronous, PURE multi-horizon cumulative/velocity Guard usable in * any `policy.business[]`. Reads the injected `AggregateSnapshot` (052) and * fires the configured Decision when the decision's projected contribution * would push ANY configured horizon over its cap (`committed + increment > max`, * cap allowed). Otherwise returns null and lets adjudication continue. * * Deterministic & side-effect-free (§D / invariant #5): given the same injected * snapshot + envelope + state it returns the same Decision, so the recorded * decision replays bit-identically. It never authorizes EXECUTE (§C / invariant * #7) — under-limit it returns null; over-limit it only raises friction. */ export declare function createCumulativeVelocityGuard(options: CumulativeVelocityGuardOptions): Guard; //# sourceMappingURL=rate-limit.d.ts.map