/** * GuardFireStats — in-memory accumulator of guard fire counts. * * A LearningSink that bucketizes (guardName, guardPhase, decisionKind, day) * tuples into rolling-window counters. Used by the console governance page * to render "which guards fire how often and produce which decisions" charts. * * **In-memory by design.** Survives process restart through the optional * `PersistentStore` adapter (Phase 1.5C). The default accumulator is a Map * keyed by `(guardName|guardPhase|decisionKind|day)`. Total memory cost is * bounded by `O(packs × guards × decisions × days)` — for a typical adopter * (3 packs, ~30 guards, 6 decisions, 30 days) this is ~16k entries, well * inside JS heap budgets. * * Query results are filtered by the rolling-window cutoff at read time — * stale buckets older than the window are excluded but not deleted (callers * may want longer windows on a subsequent query). Compaction is a future * concern wrapped behind `PersistentStore`. */ import type { LearningEvent, LearningSink } from "./learning.js"; export type GuardPhase = "state" | "taint" | "auth" | "business"; export interface GuardFireBucket { readonly guardName: string; readonly guardPhase: GuardPhase; readonly decisionKind: LearningEvent["decisionKind"]; /** YYYY-MM-DD UTC. */ readonly day: string; readonly count: number; } export interface GuardFireStatsQuery { /** ISO-8601 lower bound (inclusive). Buckets with `day >= since[:10]` count. */ readonly since: string; /** Optional pack filter — applied by the caller before invoking record(). */ readonly packId?: string; } /** * Adapter for persisting stats to durable storage. When supplied, every * `record()` writes through (best-effort — write failures do not block * telemetry) and every `query()` first reads from durable storage before * unioning with in-memory. */ export interface GuardFireStatsStore { write(bucket: GuardFireBucket): void | Promise; readSince(since: string, packId?: string): readonly GuardFireBucket[] | Promise; } export interface GuardFireStatsOptions { /** Optional persistent backing store (Phase 1.5C). */ readonly store?: GuardFireStatsStore; /** * Optional pack-resolver: given an intentKind, return the packId. When * supplied, the `packId` is attached to each bucket so queries can filter * by pack. Without a resolver, packId remains undefined and pack filtering * is a no-op. */ readonly resolvePackId?: (intentKind: string) => string | undefined; /** * MemoryReviewer-003: hard cap on the number of in-memory buckets. When the * accumulator exceeds this many distinct `(guard, phase, decision, day, pack)` * tuples, the oldest buckets (lowest `day`) are compacted away on write — * a rolling window that keeps recent telemetry hot and bounds heap. * * In-memory telemetry only — eviction never affects adjudication output, and * a `store` (when supplied) retains the full history regardless of this cap. * Defaults to {@link DEFAULT_MAX_BUCKETS}. */ readonly maxBuckets?: number; } /** * Default in-memory bucket cap. Sized for a generous adopter (more packs / * guards / a wider day window than the typical ~16k estimate above) while * still bounding heap so a long-lived process cannot grow the accumulator * without limit. Override via {@link GuardFireStatsOptions.maxBuckets}. */ export declare const DEFAULT_MAX_BUCKETS = 100000; /** * In-memory accumulator. Implements `LearningSink` so it can be plugged into * `RuntimeContext.learning` or `setLearningSink()` directly. * * ── 052 — the durable aggregate-counting SUBSTRATE (single owner) ────────── * Plan 052 OWNS this counting substrate: the coalescing/delta-write counter * here (`recordOutcome` writes the per-call DELTA `count:1`, NOT the merged * running total) plus the additive Postgres upsert it writes through to * (`audit-postgres` `UPSERT_GUARD_STAT_SQL`: `ON CONFLICT (...) DO UPDATE SET * count = audit_guard_stats.count + EXCLUDED.count`) and the migration-006 PK * arbiter. That trio makes counting ATOMIC/COALESCING under concurrency: each * write is an additive single-statement upsert (no read-modify-write, no * over-commit TOCTOU race — distinct from the ephemeral Redis park counter's * `INCR→EXPIRE→check→DECR` sequence in `runtime/defer-park.ts`). * * **051 and 053 CONSUME this substrate READ-ONLY.** 051's velocity/limit guards * read the aggregate counts through `queryAsync` (the store-direct path); 053's * reservation extends the SAME additive upsert template. Consumers MUST NOT * re-implement the counter or write through a non-additive path — doing so * re-introduces the double-count (triangular `N(N+1)/2`) the delta-write seam * here exists to prevent (see `recordOutcome` and `guard-stats.test.ts`'s * assert-6-not-9 regression). */ export declare class GuardFireStats implements LearningSink { private readonly memo; private readonly store?; private readonly resolvePackId?; private readonly maxBuckets; constructor(options?: GuardFireStatsOptions); /** * Rolling-window compaction: while the accumulator exceeds `maxBuckets`, * evict the bucket(s) with the oldest `day`. Buckets sharing the oldest day * are evicted in insertion order (Map iteration order) so the eviction is * deterministic. Telemetry-only — never touches decision output. */ private compact; recordOutcome(event: LearningEvent): void; /** * Snapshot of all in-memory buckets that satisfy the window + pack filter. * Does NOT consult the persistent store — see `queryAsync` for the * union of memory + store. */ query(q: GuardFireStatsQuery): readonly GuardFireBucket[]; /** * Durable view of the stats. Used by the admin-sdk query handler. * * When a `store` is configured it is the source of truth: every `record()` * writes a +1 delta through to it, and its additive upsert aggregates the * total (across replicas, too). The in-memory `memo` is a per-replica * write-through copy of the SAME events, so unioning the two would * double-count — hence we return the store's reads directly. Memory is the * fallback only when no store is configured. * * Caveat: a store write is best-effort (it must never block adjudication), * so an event whose write failed is reflected in `memo` but not the store * and will be absent here — an acceptable gap for best-effort telemetry, * and the opposite of the prior over-count. */ queryAsync(q: GuardFireStatsQuery): Promise; } //# sourceMappingURL=guard-stats.d.ts.map