1"""Persistence backends for AI governance state.
2
3Defines the :class:`GovernancePersistence` protocol and two concrete
4implementations:
5
6- :class:`InMemoryGovernancePersistence` — process-local state, suitable for
7 development, testing, and single-instance deployments.
8- :class:`RedisGovernancePersistence` — distributed state backed by a
9 :class:`~lexigram.contracts.cache.CacheBackendProtocol`, enabling multi-replica
10 consistency for rate limiting and budget enforcement.
11
12The :class:`~lexigram.ai.governance.manager.AIGovernanceManager` accepts any
13implementation via constructor injection so the storage strategy is swappable
14without changing governance logic.
15"""
16
17from __future__ import annotations
18
19import time
20from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
21
22from lexigram.ai.governance.exceptions import GovernancePersistenceError
23
24if TYPE_CHECKING:
25 from lexigram.contracts.data import DatabaseProviderProtocol
26 from lexigram.contracts.infra.cache import CacheBackendProtocol
27 from lexigram.contracts.infra.cache.exceptions import CacheError
28 from lexigram.result import Result
29
30__all__ = [
31 "DatabaseGovernancePersistence",
32 "GovernancePersistence",
33 "InMemoryGovernancePersistence",
34 "RedisGovernancePersistence",
35]
36
37
38@runtime_checkable
39class GovernancePersistence(Protocol):
40 """Storage protocol for governance counters and spend tallies.
41
42 Implementations must be safe for concurrent async access. All methods
43 are *coroutines* to allow either local (in-process) or remote (Redis,
44 database) storage without changing the call-site.
45 """
46
47 async def incr_requests(
48 self,
49 key: str,
50 window: float,
51 ) -> int:
52 """Record a new request and return the request count within the window.
53
54 Args:
55 key: Bucket key (e.g. ``"global"`` or a user/tenant id).
56 window: Rolling window size in seconds.
57
58 Returns:
59 Number of requests (including the current one) inside the window.
60 """
61 ...
62
63 async def add_spend(
64 self,
65 key: str,
66 amount: float,
67 ttl: int,
68 ) -> float:
69 """Add ``amount`` to the spend accumulator and return the new total.
70
71 Args:
72 key: Bucket key (e.g. ``"global:2025-06"``).
73 amount: Cost amount to add.
74 ttl: Time-to-live for the accumulator entry in seconds.
75
76 Returns:
77 Updated total spend.
78 """
79 ...
80
81 async def get_spend(self, key: str) -> float:
82 """Return the current accumulated spend for *key*.
83
84 Args:
85 key: Bucket key.
86
87 Returns:
88 Current spend; ``0.0`` if no data recorded.
89 """
90 ...
91
92 # -- Gauge methods -------------------------------------------------------
93
94 async def read_gauge(self, key: str) -> float:
95 """Read the current gauge value for *key*.
96
97 Args:
98 key: Gauge key (e.g. ``"tenant:gpt4:remaining"``).
99
100 Returns:
101 Current gauge value; ``0.0`` if no data recorded.
102 """
103 ...
104
105 async def write_gauge(self, key: str, value: float, ttl: int) -> None:
106 """Set the gauge *value* for *key* with a TTL.
107
108 Args:
109 key: Gauge key.
110 value: Float gauge value.
111 ttl: Time-to-live in seconds.
112 """
113 ...
114
115 async def incr_gauge(self, key: str, delta: float, ttl: int) -> float:
116 """Atomically increment (or decrement) the gauge for *key*.
117
118 Args:
119 key: Gauge key.
120 delta: Amount to add (can be negative).
121 ttl: Time-to-live in seconds.
122
123 Returns:
124 The gauge value after applying *delta*.
125 """
126 ...
127
128 # -- Calendar methods ----------------------------------------------------
129
130 async def add_calendar_entry(self, key: str, timestamp: float, ttl: int) -> None:
131 """Record a timestamp entry in a calendar-style bucket.
132
133 Args:
134 key: Calendar bucket key.
135 timestamp: Unix timestamp to record.
136 ttl: Time-to-live in seconds for the bucket.
137 """
138 ...
139
140 async def query_calendar(self, key: str, start: float, end: float) -> list[float]:
141 """Return all timestamps in *key* that fall within [*start*, *end*].
142
143 Args:
144 key: Calendar bucket key.
145 start: Unix timestamp, start of range (inclusive).
146 end: Unix timestamp, end of range (inclusive).
147
148 Returns:
149 List of matching timestamps (chronological order).
150 """
151 ...
152
153 async def decr_gauge(self, key: str, amount: float, ttl: int) -> float:
154 """Decrement a gauge key by *amount*.
155
156 Default implementation delegates to :meth:`incr_gauge` with a
157 negative delta.
158 """
159 return await self.incr_gauge(key, -amount, ttl)
160
161 async def incr_calendar(
162 self, key: str, period: str, amount: float, ttl: int
163 ) -> float:
164 """Accumulate *amount* in a calendar-window bucket.
165
166 Delegates to :meth:`add_spend` with a period-scoped key (``key`` +
167 ``:`` + ``period``).
168 """
169 return await self.add_spend(f"{key}:{period}", amount, ttl)
170
171 async def get_calendar(self, key: str, period: str) -> float:
172 """Read the calendar-window accumulator for *key* + *period*.
173
174 Delegates to :meth:`get_spend` with a period-scoped key.
175 """
176 return await self.get_spend(f"{key}:{period}")
177
178
179# ---------------------------------------------------------------------------
180# In-memory implementation
181# ---------------------------------------------------------------------------
182
183
184class InMemoryGovernancePersistence:
185 """Process-local governance persistence using plain Python dicts.
186
187 Request counts are tracked with a sliding-window approach (list of
188 monotonic timestamps). Spend totals are stored as plain floats.
189
190 This implementation is **not** suitable for multi-process or
191 multi-replica deployments. Use :class:`RedisGovernancePersistence`
192 in production.
193 """
194
195 def __init__(self) -> None:
196 self._request_buckets: dict[str, list[float]] = {}
197 self._spend_totals: dict[str, float] = {}
198 self._gauges: dict[str, float] = {}
199 self._calendar: dict[str, list[tuple[float, float]]] = {}
200
201 async def incr_requests(self, key: str, window: float) -> int:
202 now = time.monotonic()
203 bucket = self._request_buckets.setdefault(key, [])
204 # Prune entries outside the window, then record current request
205 self._request_buckets[key] = [t for t in bucket if now - t <= window]
206 self._request_buckets[key].append(now)
207 return len(self._request_buckets[key])
208
209 async def add_spend(self, key: str, amount: float, ttl: int) -> float:
210 current = self._spend_totals.get(key, 0.0)
211 updated = current + amount
212 self._spend_totals[key] = updated
213 return updated
214
215 async def get_spend(self, key: str) -> float:
216 return self._spend_totals.get(key, 0.0)
217
218 async def read_gauge(self, key: str) -> float:
219 return self._gauges.get(key, 0.0)
220
221 async def write_gauge(self, key: str, value: float, ttl: int) -> None:
222 self._gauges[key] = value
223
224 async def incr_gauge(self, key: str, delta: float, ttl: int) -> float:
225 current = self._gauges.get(key, 0.0)
226 updated = max(current + delta, 0.0)
227 self._gauges[key] = updated
228 return updated
229
230 async def add_calendar_entry(self, key: str, timestamp: float, ttl: int) -> None:
231 now = time.monotonic()
232 bucket = self._calendar.setdefault(key, [])
233 bucket.append((now, timestamp))
234
235 async def query_calendar(self, key: str, start: float, end: float) -> list[float]:
236 results = []
237 bucket = self._calendar.get(key, [])
238 for _, ts in bucket:
239 if start <= ts <= end:
240 results.append(ts)
241 return results
242
243
244# ---------------------------------------------------------------------------
245# Redis-backed implementation
246# ---------------------------------------------------------------------------
247
248_32_DAYS_SECONDS = 32 * 24 * 3600
249
250
251def _cache_payload(value: object, *, key: str) -> Any:
252 """Extract the payload of a cache backend result, raising on failure.
253
254 Protocol-compliant backends return ``Result[Any | None, CacheError]``;
255 plain values are treated as an ``Ok`` payload. An ``Err`` result is an
256 infrastructure failure and raises :class:`GovernancePersistenceError`
257 instead of being silently treated as a missing value (the previous
258 behavior, which let failures fail open).
259
260 Args:
261 value: Return value of ``CacheBackendProtocol.get`` / ``set``.
262 key: Cache key the call was made for (included in the error).
263
264 Returns:
265 The payload — ``Any``, matching the cache protocol's payload type —
266 or ``None`` for a successful miss.
267
268 Raises:
269 GovernancePersistenceError: When the backend reported failure.
270 """
271 if hasattr(value, "is_ok"):
272 result = cast("Result[Any, CacheError]", value)
273 if result.is_ok():
274 return result.unwrap_or(None)
275 error = result.unwrap_err()
276 raise GovernancePersistenceError(
277 f"cache backend failed for key={key}: {error}"
278 ) from error
279 return value
280
281
282class RedisGovernancePersistence:
283 """Distributed governance persistence backed by a Lexigram CacheBackendProtocol.
284
285 Request windows use a sorted-set approach:
286 - Each request is stored as a member with its Unix timestamp as score.
287 - Expired members (score < ``now - window``) are pruned on every read.
288
289 Spend totals are stored as plain string floats with a configurable TTL
290 so that monthly counters expire automatically.
291
292 Args:
293 cache: A :class:`~lexigram.contracts.cache.CacheBackendProtocol` that has been
294 connected and is ready to accept commands. The backend is
295 expected to be Redis-compatible.
296 """
297
298 _REQUEST_KEY_PREFIX = "ai:gov:req:"
299 _SPEND_KEY_PREFIX = "ai:gov:spend:"
300 _GAUGE_KEY_PREFIX = "ai:gov:gauge:"
301 _CALENDAR_KEY_PREFIX = "ai:gov:cal:"
302
303 def __init__(self, cache: CacheBackendProtocol) -> None:
304 self._cache = cache
305
306 async def incr_requests(self, key: str, window: float) -> int:
307 """Use a sorted set to implement a sliding window counter.
308
309 Falls back to an approximate counter if the backend does not support
310 sorted-set operations (e.g. a simple in-memory mock). Infrastructure
311 failures are not masked: the exception propagates so the caller can
312 apply the configured governance decision (fail-closed by default).
313
314 Args:
315 key: Bucket key (e.g. ``"global"`` or a user/tenant id).
316 window: Rolling window size in seconds.
317
318 Returns:
319 Number of requests (including the current one) inside the window.
320
321 Raises:
322 GovernancePersistenceError: When the cache backend reports failure
323 (``Err`` result).
324 OSError, ConnectionError, RuntimeError, ValueError, TypeError:
325 Propagated from the cache backend when it raises.
326 """
327 redis_key = f"{self._REQUEST_KEY_PREFIX}{key}"
328 now = time.time()
329 cutoff = now - window
330
331 try:
332 # Try native sorted-set operations (Redis / ioredis)
333 backend = self._cache # CacheBackendProtocol may expose raw client
334 raw = getattr(backend, "_client", None) or getattr(backend, "client", None)
335 if raw is not None and hasattr(raw, "zremrangebyscore"):
336 # Remove expired entries
337 await raw.zremrangebyscore(redis_key, "-inf", cutoff)
338 # Add current request
339 await raw.zadd(redis_key, {str(now): now})
340 # Expire the key after the window to avoid unbounded growth
341 await raw.expire(redis_key, int(window) + 1)
342 count: int = await raw.zcard(redis_key)
343 return count
344 except (OSError, ConnectionError, RuntimeError, AttributeError):
345 pass
346
347 # Fallback: simple increment counter (less precise but always works)
348 counter_key = f"{redis_key}:count"
349 raw_val = _cache_payload(await self._cache.get(counter_key), key=counter_key)
350 current = int(raw_val) + 1 if raw_val is not None else 1
351 _cache_payload(
352 await self._cache.set(counter_key, str(current), ttl=int(window) + 1),
353 key=counter_key,
354 )
355 return current
356
357 async def add_spend(self, key: str, amount: float, ttl: int) -> float:
358 """Add ``amount`` to the spend accumulator and return the new total.
359
360 Infrastructure failures propagate (fail-closed by default at the
361 manager); no value is invented on error.
362
363 Args:
364 key: Bucket key (e.g. ``"global:2025-06"``).
365 amount: Cost amount to add.
366 ttl: Time-to-live for the accumulator entry in seconds.
367
368 Returns:
369 Updated total spend.
370
371 Raises:
372 GovernancePersistenceError: When the cache backend reports failure
373 (``Err`` result, read or write).
374 OSError, ConnectionError, RuntimeError, ValueError, TypeError:
375 Propagated from the cache backend when it raises.
376 """
377 redis_key = f"{self._SPEND_KEY_PREFIX}{key}"
378 raw = _cache_payload(await self._cache.get(redis_key), key=redis_key)
379 current = float(raw) if raw is not None else 0.0
380 updated = current + amount
381 _cache_payload(
382 await self._cache.set(redis_key, str(updated), ttl=ttl),
383 key=redis_key,
384 )
385 return updated
386
387 async def get_spend(self, key: str) -> float:
388 """Return the current accumulated spend for *key*.
389
390 Args:
391 key: Bucket key.
392
393 Returns:
394 Current spend; ``0.0`` if no data recorded.
395
396 Raises:
397 GovernancePersistenceError: When the cache backend reports failure
398 (``Err`` result).
399 OSError, ConnectionError, RuntimeError, ValueError, TypeError:
400 Propagated from the cache backend when it raises.
401 """
402 redis_key = f"{self._SPEND_KEY_PREFIX}{key}"
403 raw = _cache_payload(await self._cache.get(redis_key), key=redis_key)
404 return float(raw) if raw is not None else 0.0
405
406 async def read_gauge(self, key: str) -> float:
407 """Read the current gauge value for *key*.
408
409 Args:
410 key: Gauge key (e.g. ``"tenant:gpt4:remaining"``).
411
412 Returns:
413 Current gauge value; ``0.0`` if no data recorded.
414
415 Raises:
416 GovernancePersistenceError: When the cache backend reports failure
417 (``Err`` result).
418 OSError, ConnectionError, RuntimeError, ValueError, TypeError:
419 Propagated from the cache backend when it raises.
420 """
421 redis_key = f"{self._GAUGE_KEY_PREFIX}{key}"
422 raw = _cache_payload(await self._cache.get(redis_key), key=redis_key)
423 return float(raw) if raw is not None else 0.0
424
425 async def write_gauge(self, key: str, value: float, ttl: int) -> None:
426 redis_key = f"{self._GAUGE_KEY_PREFIX}{key}"
427 try:
428 await self._cache.set(redis_key, str(value), ttl=ttl)
429 except (OSError, ConnectionError, RuntimeError):
430 pass
431
432 async def incr_gauge(self, key: str, delta: float, ttl: int) -> float:
433 """Atomically increment (or decrement) the gauge for *key*.
434
435 Args:
436 key: Gauge key.
437 delta: Amount to add (can be negative).
438 ttl: Time-to-live in seconds.
439
440 Returns:
441 The gauge value after applying *delta*.
442
443 Raises:
444 GovernancePersistenceError: When the cache backend reports failure
445 (``Err`` result, read or write).
446 OSError, ConnectionError, RuntimeError, ValueError, TypeError:
447 Propagated from the cache backend when it raises.
448 """
449 redis_key = f"{self._GAUGE_KEY_PREFIX}{key}"
450 raw = _cache_payload(await self._cache.get(redis_key), key=redis_key)
451 current = float(raw) if raw is not None else 0.0
452 updated = max(current + delta, 0.0)
453 _cache_payload(
454 await self._cache.set(redis_key, str(updated), ttl=ttl),
455 key=redis_key,
456 )
457 return updated
458
459 async def add_calendar_entry(self, key: str, timestamp: float, ttl: int) -> None:
460 redis_key = f"{self._CALENDAR_KEY_PREFIX}{key}"
461 try:
462 raw_result = await self._cache.get(redis_key)
463 if hasattr(raw_result, "is_ok"):
464 raw = raw_result.unwrap_or(None) if raw_result.is_ok() else None
465 else:
466 raw = raw_result
467 entries: list[float] = []
468 if raw is not None:
469 entries = [float(v) for v in raw.split(",") if v]
470 entries.append(timestamp)
471 await self._cache.set(redis_key, ",".join(str(v) for v in entries), ttl=ttl)
472 except (OSError, ConnectionError, RuntimeError, ValueError, TypeError):
473 pass
474
475 async def query_calendar(self, key: str, start: float, end: float) -> list[float]:
476 redis_key = f"{self._CALENDAR_KEY_PREFIX}{key}"
477 try:
478 raw_result = await self._cache.get(redis_key)
479 if hasattr(raw_result, "is_ok"):
480 raw = raw_result.unwrap_or(None) if raw_result.is_ok() else None
481 else:
482 raw = raw_result
483 if raw is None:
484 return []
485 entries = [float(v) for v in raw.split(",") if v]
486 return sorted([v for v in entries if start <= v <= end])
487 except (OSError, ConnectionError, RuntimeError, ValueError, TypeError):
488 return []
489
490
491# ---------------------------------------------------------------------------
492# Database-backed implementation
493# ---------------------------------------------------------------------------
494
495_CREATE_REQUESTS_TABLE = """
496CREATE TABLE IF NOT EXISTS ai_governance_requests (
497 id INTEGER PRIMARY KEY AUTOINCREMENT,
498 key TEXT NOT NULL,
499 ts REAL NOT NULL
500)
501"""
502
503_CREATE_SPEND_TABLE = """
504CREATE TABLE IF NOT EXISTS ai_governance_spend (
505 key TEXT NOT NULL PRIMARY KEY,
506 amount REAL NOT NULL DEFAULT 0,
507 expires_at REAL NOT NULL
508)
509"""
510
511_INSERT_REQUEST = "INSERT INTO ai_governance_requests (key, ts) VALUES (?, ?)"
512_DELETE_EXPIRED_REQUESTS = "DELETE FROM ai_governance_requests WHERE key = ? AND ts < ?"
513_COUNT_REQUESTS = "SELECT COUNT(*) AS cnt FROM ai_governance_requests WHERE key = ?"
514
515_UPSERT_SPEND = (
516 "INSERT INTO ai_governance_spend (key, amount, expires_at) VALUES (?, ?, ?) "
517 "ON CONFLICT (key) DO UPDATE SET "
518 "amount = amount + excluded.amount, "
519 "expires_at = excluded.expires_at"
520)
521
522_GET_SPEND = "SELECT amount FROM ai_governance_spend WHERE key = ? AND expires_at >= ?"
523
524_GAUGE_TABLE = """
525CREATE TABLE IF NOT EXISTS ai_governance_gauges (
526 key TEXT NOT NULL PRIMARY KEY,
527 value REAL NOT NULL DEFAULT 0.0,
528 expires_at REAL NOT NULL
529)
530"""
531_UPSERT_GAUGE = (
532 "INSERT INTO ai_governance_gauges (key, value, expires_at) VALUES (?, ?, ?) "
533 "ON CONFLICT (key) DO UPDATE SET "
534 "value = excluded.value, "
535 "expires_at = excluded.expires_at"
536)
537_GET_GAUGE = "SELECT value FROM ai_governance_gauges WHERE key = ? AND expires_at >= ?"
538
539_CALENDAR_TABLE = """
540CREATE TABLE IF NOT EXISTS ai_governance_calendar (
541 id INTEGER PRIMARY KEY AUTOINCREMENT,
542 key TEXT NOT NULL,
543 ts REAL NOT NULL
544)
545"""
546_INSERT_CALENDAR = "INSERT INTO ai_governance_calendar (key, ts) VALUES (?, ?)"
547_QUERY_CALENDAR = (
548 "SELECT ts FROM ai_governance_calendar "
549 "WHERE key = ? AND ts >= ? AND ts <= ? ORDER BY ts ASC"
550)
551
552
553class DatabaseGovernancePersistence:
554 """SQL-backed governance persistence using :class:`~lexigram.contracts.data.DatabaseProviderProtocol`.
555
556 Stores request timestamps in ``ai_governance_requests`` and spend totals
557 in ``ai_governance_spend``. Both tables are created lazily on first use.
558
559 This backend is suitable for multi-replica deployments where Redis is not
560 available but a shared relational database is. Row-level locking provided
561 by the database engine ensures counter consistency.
562
563 Args:
564 db: A connected :class:`~lexigram.contracts.data.DatabaseProviderProtocol`
565 resolved from the DI container.
566 """
567
568 def __init__(self, db: DatabaseProviderProtocol) -> None:
569 self._db = db
570 self._initialised = False
571
572 async def _ensure_tables(self) -> None:
573 """Create governance tables if they do not yet exist."""
574 if not self._initialised:
575 await self._db.execute(_CREATE_REQUESTS_TABLE)
576 await self._db.execute(_CREATE_SPEND_TABLE)
577 await self._db.execute(_GAUGE_TABLE)
578 await self._db.execute(_CALENDAR_TABLE)
579 self._initialised = True
580
581 async def incr_requests(self, key: str, window: float) -> int:
582 """Insert current timestamp, prune expired rows, return window count.
583
584 Args:
585 key: Governance bucket key.
586 window: Rolling window size in seconds.
587
588 Returns:
589 Number of requests within *window* (including the current one).
590 """
591 await self._ensure_tables()
592 now = time.time()
593 cutoff = now - window
594 await self._db.execute(_INSERT_REQUEST, [key, now])
595 await self._db.execute(_DELETE_EXPIRED_REQUESTS, [key, cutoff])
596 result = await self._db.execute_query(_COUNT_REQUESTS, [key])
597 rows = result.rows
598 return int(rows[0]["cnt"]) if rows else 1
599
600 async def add_spend(self, key: str, amount: float, ttl: int) -> float:
601 """Accumulate *amount* against *key* and return the running total.
602
603 The row is upserted with ``expires_at = now + ttl``; callers should
604 use time-period-scoped keys (e.g. ``"global:2025-06"``) so that
605 distinct budget periods never collide.
606
607 Args:
608 key: Governance bucket key.
609 amount: Cost to add.
610 ttl: Seconds until the entry should expire.
611
612 Returns:
613 Updated running total for *key*.
614 """
615 await self._ensure_tables()
616 expires_at = time.time() + ttl
617 await self._db.execute(_UPSERT_SPEND, [key, amount, expires_at])
618 result = await self._db.execute_query(_GET_SPEND, [key, time.time()])
619 rows = result.rows
620 return float(rows[0]["amount"]) if rows else amount
621
622 async def get_spend(self, key: str) -> float:
623 """Return the current accumulated spend for *key*.
624
625 Args:
626 key: Governance bucket key.
627
628 Returns:
629 Current spend; ``0.0`` if no data recorded or entry has expired.
630 """
631 await self._ensure_tables()
632 result = await self._db.execute_query(_GET_SPEND, [key, time.time()])
633 rows = result.rows
634 return float(rows[0]["amount"]) if rows else 0.0
635
636 async def read_gauge(self, key: str) -> float:
637 """Read the current gauge value for *key*."""
638 await self._ensure_tables()
639 result = await self._db.execute_query(_GET_GAUGE, [key, time.time()])
640 rows = result.rows
641 return float(rows[0]["value"]) if rows else 0.0
642
643 async def write_gauge(self, key: str, value: float, ttl: int) -> None:
644 """Set the gauge *value* for *key* with a TTL."""
645 await self._ensure_tables()
646 expires_at = time.time() + ttl
647 await self._db.execute(_UPSERT_GAUGE, [key, value, expires_at])
648
649 async def incr_gauge(self, key: str, delta: float, ttl: int) -> float:
650 """Atomically increment (or decrement) the gauge for *key*."""
651 await self._ensure_tables()
652 now = time.time()
653 result = await self._db.execute_query(_GET_GAUGE, [key, now])
654 rows = result.rows
655 current = float(rows[0]["value"]) if rows else 0.0
656 updated = max(current + delta, 0.0)
657 expires_at = now + ttl
658 await self._db.execute(_UPSERT_GAUGE, [key, updated, expires_at])
659 return updated
660
661 async def add_calendar_entry(self, key: str, timestamp: float, ttl: int) -> None:
662 """Record a timestamp entry in a calendar-style bucket."""
663 await self._ensure_tables()
664 await self._db.execute(_INSERT_CALENDAR, [key, timestamp])
665
666 async def query_calendar(self, key: str, start: float, end: float) -> list[float]:
667 """Return all timestamps in *key* that fall within range."""
668 await self._ensure_tables()
669 result = await self._db.execute_query(_QUERY_CALENDAR, [key, start, end])
670 rows = result.rows
671 return [float(r["ts"]) for r in rows] if rows else []