/** * Token-usage telemetry store (ADR-135, follows ADR-120). * * Folds the loop's `onTokenUsage` hook into per-session AND per-tenant * cumulative token counters, and appends a bounded budget-exhaustion event * whenever a counter crosses its configured cap. It backs the admin-sdk * `governance.tokenBudget` / `governance.tokenBudgetByTenant` port and the * console's Token Governance section. * * # Determinism boundary — READ THIS FIRST * * This store is **TELEMETRY, strictly OUTSIDE the determinism boundary.** It * exists to power dashboards; it must NEVER feed a kernel decision. Enforcement * stays in `createTokenBudgetGuard` (`@adjudicate/primitives`, ADR-120), whose * only inputs are `(envelope, adopter state S)` — it reads the consumed counter * from `S`, never from this store. The store and the guard are two INDEPENDENT * reads of the same upstream provider usage: the guard reads it folded into `S` * (deterministic, kernel-facing); this store reads it from `onTokenUsage` * (telemetry, dashboard-facing). There is no edge between them. * * Consequences, enforced here by construction: * - NO wall-clock on the recorded timeline: every `at` is ADOPTER-SUPPLIED * (`record(sample)` uses `sample.at` verbatim). The only `Date.now()` in * this module is the same opportunistic LRU sweep the existing * `createInMemoryMemoryStore` uses (`persistence.ts`) — it evicts old * session rows and never participates in any decision or recorded value. * - NO RNG on any decision path. Exhaustion-event ids are derived * deterministically from a monotonic insertion sequence (no `randomUUID`), * so the store stays reproducible in tests and across replays. Ids are * telemetry only — never hashed, never a kernel input. * - Replaying an envelope through the kernel re-derives the same Decision from * `(envelope, policy, S)`; the store is not consulted, so it cannot perturb * replay. Best-effort hook loss only undercounts telemetry — it never * changes a Decision. * * # Bounded cardinality * * Session counters are LRU-bounded (`maxSessions`, default 10_000) so unbounded * session-id churn cannot grow the store without bound (this doubles as the * session-churn-evasion defence below). Exhaustion events live in a fixed- * capacity newest-first ring (`maxEvents`, default 10_000, mirroring * `DEFAULT_MAX_EMERGENCY_EVENTS` in `@adjudicate/audit`). Tenant counters are * bounded by the (small) real tenant cardinality. * * # Session-churn evasion → tenant cap is the backstop * * An adversary minting a fresh `sessionId` per request resets the SESSION * counter and slips under any per-session cap. The TENANT cap is the backstop: * per-tenant `consumed` aggregates across ALL of a tenant's sessions regardless * of session churn, so churn within a tenant still trips `tenantBudget` and the * crossing is recorded as a `scope: "tenant"` exhaustion event. The LRU bound * means a flood of throwaway session ids evicts old session rows rather than * exploding memory (and shows up as an anomalous `sessionCount` on the tenant * row). This is why threading the tenant dimension is load-bearing. */ /** * One provider-reported usage sample, attributed to a session and (optionally) * a tenant. Fed from the adapter loop's `onTokenUsage` hook. */ export interface TokenUsageSample { readonly sessionId: string; /** Omitted in single-tenant deployments. */ readonly tenantId?: string; /** * Token split for this sample. Either supply `prompt`/`completion` (mapped * from the provider's `TokenUsage.inputTokens`/`outputTokens`) and/or a * pre-summed `total`. The store coerces everything to a non-negative integer * total: `total` when finite, else `prompt + completion`; non-finite parts * contribute 0. This is the single accumulation unit. */ readonly prompt?: number; readonly completion?: number; readonly total?: number; /** * ISO-8601 timestamp — ADOPTER-SUPPLIED. Used verbatim for the recorded * `lastAt` and any exhaustion-event `at`. The store never calls `Date.now()` * for a recorded value (determinism boundary — see module docs). */ readonly at: string; } /** * Configured caps the store compares cumulative consumption against. Display + * exhaustion-detection only — never enforcement (that is the guard's job). */ export interface TokenBudgetConfig { /** Per-session cap. */ readonly sessionBudget?: number; /** Per-tenant cap. */ readonly tenantBudget?: number; } /** Per-session cumulative view (back-compat with the existing session shape). */ export interface SessionConsumption { readonly sessionId: string; readonly tenantId?: string; readonly consumed: number; /** * Cumulative prompt/input tokens, when the provider reported a split * (sample.prompt). Zero when only a pre-summed `total` was recorded. Used by * `applyCostTable` for read-time split pricing — never an enforcement input. */ readonly promptConsumed: number; /** Cumulative completion/output tokens, when the split was reported (else 0). */ readonly completionConsumed: number; readonly budget?: number; readonly remaining?: number; readonly lastAt: string; } /** Per-tenant cumulative view (aggregated across the tenant's sessions). */ export interface TenantConsumption { readonly tenantId: string; readonly consumed: number; /** Cumulative prompt/input tokens across the tenant's sessions (split-aware; else 0). */ readonly promptConsumed: number; /** Cumulative completion/output tokens across the tenant's sessions (split-aware; else 0). */ readonly completionConsumed: number; readonly budget?: number; readonly remaining?: number; readonly sessionCount: number; readonly lastAt: string; } /** One budget-exhaustion crossing — a telemetry read-model, NOT an audit record. */ export interface TokenExhaustionEvent { /** Deterministic id (`evt:`), telemetry only — never hashed. */ readonly id: string; /** Adopter-supplied ISO timestamp of the crossing sample. */ readonly at: string; /** Closed two-value scope. */ readonly scope: "session" | "tenant"; /** Present iff `scope === "session"`. */ readonly sessionId?: string; /** Present for tenant scope; carried on session events when known. */ readonly tenantId?: string; readonly consumed: number; /** The cap that was crossed. */ readonly budget: number; } /** Filter for the per-session view. */ export interface SessionsFilter { readonly sessionId?: string; readonly tenantId?: string; readonly since?: string; } /** Filter for the per-tenant view. */ export interface TenantsFilter { readonly tenantId?: string; readonly since?: string; } /** Filter for the exhaustion-event log. */ export interface ExhaustionEventsFilter { readonly scope?: "session" | "tenant"; readonly tenantId?: string; /** Hard cap on the returned (newest-first) slice. */ readonly limit?: number; } /** * Token-usage telemetry store contract. `record` is the single mutation, * called from the adopter's `onTokenUsage` callback. The read methods back the * admin-sdk port. All methods are synchronous on the in-memory impl; a future * Redis impl would make them async — the port adapts via `Promise.resolve`. */ export interface TokenUsageStore { /** Fold one usage sample into the session + tenant counters; append an exhaustion event on a crossing. */ record(sample: TokenUsageSample): void; /** Per-session view (newest-activity first). */ sessions(filter?: SessionsFilter): SessionConsumption[]; /** Per-tenant view (newest-activity first). */ tenants(filter?: TenantsFilter): TenantConsumption[]; /** Bounded, newest-first exhaustion-event log. */ exhaustionEvents(filter?: ExhaustionEventsFilter): TokenExhaustionEvent[]; /** Sum of all session consumption (== sum of tenant consumption for attributed samples). */ totalConsumed(): number; } export interface CreateInMemoryTokenUsageStoreOptions { /** Default per-session AND per-tenant caps. */ readonly sessionBudget?: number; /** Default per-tenant cap (alias surfaced for symmetry with the guard's `tenantBudget`). */ readonly perTenantBudget?: number; /** Per-session cap (alias of `sessionBudget`; `sessionBudget` wins if both set). */ readonly perSessionBudget?: number; /** Override caps per tenant (e.g. an enterprise tenant with a higher cap). */ readonly perTenantBudgets?: ReadonlyMap; /** LRU bound on session rows (session-id churn defence). Default 10_000. */ readonly maxSessions?: number; /** Exhaustion-event ring-buffer bound. Default 10_000. */ readonly maxEvents?: number; /** * Generic `capacity` — when set, becomes the default for BOTH `maxSessions` * and `maxEvents` (explicit `maxSessions`/`maxEvents` still win). Convenience * for callers who want one bound. */ readonly capacity?: number; } /** * In-memory `TokenUsageStore`. Mirrors the lifecycle of the existing * `createInMemoryMemoryStore` / `createInMemoryConfirmationStore`: a Map-backed * ref impl with an opportunistic LRU bound and a fixed-capacity event ring. * Clock/RNG-free on every recorded value (see module docs). */ export declare function createInMemoryTokenUsageStore(opts?: CreateInMemoryTokenUsageStoreOptions): TokenUsageStore; //# sourceMappingURL=token-usage-store.d.ts.map