Coverage for astrocyte/lifecycle.py: 100%
56 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-10-29 22:10 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-10-29 22:10 +0000
1"""Memory lifecycle management — TTL, legal hold, compliance forget.
3See docs/_design/memory-lifecycle.md for the design specification.
4All policy functions are sync (Rust migration candidates).
5"""
7from __future__ import annotations
9from datetime import datetime, timezone
11from astrocyte.config import LifecycleConfig
12from astrocyte.errors import LegalHoldActive
13from astrocyte.types import LegalHold, LifecycleAction
16class LifecycleManager:
17 """Manages memory lifecycle: TTL evaluation and legal holds.
19 Holds come from two places: ones set in this process (``set_legal_hold``,
20 kept in memory) and ones persisted in the store (``Astrocyte.place_legal_hold``),
21 which the caller loads with :meth:`set_stored_holds` before each check so
22 every process and replica sees the same holds. A bank is under hold if
23 either has one. TTL evaluation is a pure sync function — no I/O, Rust-portable.
24 """
26 def __init__(self, config: LifecycleConfig) -> None:
27 self._config = config
28 self._holds: dict[str, LegalHold] = {} # key: "{bank_id}\x00{hold_id}"
29 self._stored: dict[str, list[LegalHold]] = {} # bank_id → holds as last read from the store
31 # ── Legal holds (sync) ──
33 _SEP = "\x00" # Null byte separator — cannot appear in bank_id or hold_id strings
35 def set_legal_hold(self, bank_id: str, hold_id: str, reason: str, *, set_by: str = "user:api") -> LegalHold:
36 """Place a bank under legal hold."""
37 key = f"{bank_id}{self._SEP}{hold_id}"
38 hold = LegalHold(
39 hold_id=hold_id,
40 bank_id=bank_id,
41 reason=reason,
42 set_at=datetime.now(timezone.utc),
43 set_by=set_by,
44 )
45 self._holds[key] = hold
46 return hold
48 def release_legal_hold(self, bank_id: str, hold_id: str) -> bool:
49 """Release a legal hold. Returns True if hold existed."""
50 key = f"{bank_id}{self._SEP}{hold_id}"
51 return self._holds.pop(key, None) is not None
53 def set_stored_holds(self, bank_id: str, holds: list[LegalHold]) -> None:
54 """The bank's persisted holds, as just read from the store."""
55 self._stored[bank_id] = list(holds)
57 def is_under_hold(self, bank_id: str) -> bool:
58 """Check if any legal hold is active on this bank."""
59 return bool(self.get_holds(bank_id))
61 def get_holds(self, bank_id: str) -> list[LegalHold]:
62 """Get all active holds for a bank: this process's and the persisted ones."""
63 prefix = f"{bank_id}{self._SEP}"
64 local = [h for k, h in self._holds.items() if k.startswith(prefix)]
65 ids = {h.hold_id for h in local}
66 return local + [h for h in self._stored.get(bank_id, []) if h.hold_id not in ids]
68 def check_forget_allowed(self, bank_id: str) -> None:
69 """Raise LegalHoldActive if bank is under hold."""
70 holds = self.get_holds(bank_id)
71 if holds:
72 raise LegalHoldActive(bank_id=bank_id, hold_id=holds[0].hold_id)
74 # ── TTL evaluation (sync, pure) ──
76 def evaluate_memory_ttl(
77 self,
78 memory_id: str,
79 bank_id: str,
80 created_at: datetime | None,
81 last_recalled_at: datetime | None,
82 tags: list[str] | None,
83 fact_type: str | None,
84 now: datetime,
85 ) -> LifecycleAction:
86 """Evaluate TTL policy for a single memory. Sync, pure function.
88 Returns LifecycleAction with action="archive"|"delete"|"keep".
89 """
90 if not self._config.enabled:
91 return LifecycleAction(memory_id=memory_id, action="keep", reason="lifecycle_disabled")
93 # Legal hold blocks all lifecycle actions
94 if self.is_under_hold(bank_id):
95 return LifecycleAction(memory_id=memory_id, action="keep", reason="legal_hold")
97 ttl = self._config.ttl
99 # Check exempt tags
100 if tags and ttl.exempt_tags:
101 if any(t in ttl.exempt_tags for t in tags):
102 return LifecycleAction(memory_id=memory_id, action="keep", reason="exempt")
104 # Determine archive threshold (may be overridden by fact_type)
105 archive_days = ttl.archive_after_days
106 if fact_type and ttl.fact_type_overrides:
107 override = ttl.fact_type_overrides.get(fact_type)
108 if override is not None:
109 archive_days = override
111 # Check delete threshold (based on creation date)
112 if created_at:
113 age_days = (now - created_at).days
114 if age_days >= ttl.delete_after_days:
115 return LifecycleAction(memory_id=memory_id, action="delete", reason="ttl_expired")
117 # Check archive threshold (based on last recall or creation)
118 reference_date = last_recalled_at or created_at
119 if reference_date:
120 days_since = (now - reference_date).days
121 if days_since >= archive_days:
122 return LifecycleAction(memory_id=memory_id, action="archive", reason="ttl_unretrieved")
124 return LifecycleAction(memory_id=memory_id, action="keep", reason="recent")