import { type SpendAsset, type WindowCap } from './window.js'; /** * What the sign path needs from the spend ledger. `reserve` is the only * mutating call: it checks every cap against what is already recorded and, * only if all hold, records the new amount — atomically with respect to * other sigil-mcp processes, so two windows can't both squeeze under a cap * in the same instant. * * The ledger owns the clock. Time is sampled *inside* the critical section * so a process that read the clock, got descheduled, and then took the lock * cannot judge a competitor's later spend as "in the future" and ignore it. * Entries dated after now (another process's later sample, or a clock that * stepped back) are always counted: a spend that has happened has happened. */ export interface SpendLedger { /** Total recorded for (handle, asset) with ts > now - windowMs, including any ts > now. */ spent(handle: string, asset: SpendAsset, windowMs: number): bigint; /** * Check-then-record. Returns null on success (amount recorded), or the * deny reason (nothing recorded). A zero amount is a no-op success. */ reserve(handle: string, asset: SpendAsset, amount: bigint, caps: readonly WindowCap[]): string | null; } export declare class SpendLedgerError extends Error { constructor(message: string); } export interface SpendLedgerOpts { /** Clock override for tests. Defaults to Date.now. */ now?: () => number; } /** In-memory ledger for tests and for callers that don't persist. */ export declare class MemorySpendLedger implements SpendLedger { #private; constructor(opts?: SpendLedgerOpts); spent(handle: string, asset: SpendAsset, windowMs: number): bigint; reserve(handle: string, asset: SpendAsset, amount: bigint, caps: readonly WindowCap[]): string | null; } /** * File-backed ledger: one append-only JSONL file per portal at * `/.ledger`, lines of `{"ts":…,"asset":"wei","amount":"…"}`. * * Every read and write runs under the cross-process file lock, the clock is * sampled inside it, and each `reserve` re-reads the file before deciding, * so the decision is always made against the union of every window's * spends. * * Integrity is fail-closed: a line that does not parse makes every reserve * for that portal deny with a message naming the file, until a human fixes * or removes it. Skipping bad lines would silently loosen the cap, which is * the one thing a rate limit must not do. A crash can leave a torn final * line (writes are complete and fsynced, so only power loss does this); the * next append starts on a fresh line so the torn fragment stays isolated — * and still trips the check, on purpose. * * Entries older than the longest window sigil supports (24h) can never * influence a decision again; when more than COMPACT_AFTER of them have * piled up the file is rewritten (tmp + fsync + rename + directory fsync, * under the same lock) with only the live tail. Entries dated in the future * are always kept. * * Threat model: an attacker who can delete or rewrite this file can also * rewrite the policy file beside it and remove the cap outright, so the * ledger earns no extra protection beyond its 0600 mode inside the 0700 * sigil home; a missing file is an empty ledger. What it does defend * against is the prompt-injected *agent*, which never touches the * filesystem directly. The hash-chained audit log is the record. */ export declare class FileSpendLedger implements SpendLedger { #private; static readonly COMPACT_AFTER = 256; constructor(stateDir: string, opts?: SpendLedgerOpts); pathFor(handle: string): string; spent(handle: string, asset: SpendAsset, windowMs: number): bigint; reserve(handle: string, asset: SpendAsset, amount: bigint, caps: readonly WindowCap[]): string | null; } /** * Test-only seam: replaces the directory fsync so a test can make it fail. * Never set by production code. */ export declare const _ledgerTestHooks: { fsyncDir?: (dir: string) => void; }; //# sourceMappingURL=ledger.d.ts.map