Coverage for astrocyte/lifecycle.py: 100%
52 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-04 05:24 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-04 05:24 +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 Legal holds are stored in-memory. Persistence is out of scope for v1.
20 TTL evaluation is a pure sync function — no I/O, Rust-portable.
21 """
23 def __init__(self, config: LifecycleConfig) -> None:
24 self._config = config
25 self._holds: dict[str, LegalHold] = {} # key: "{bank_id}\x00{hold_id}"
27 # ── Legal holds (sync) ──
29 _SEP = "\x00" # Null byte separator — cannot appear in bank_id or hold_id strings
31 def set_legal_hold(self, bank_id: str, hold_id: str, reason: str, *, set_by: str = "user:api") -> LegalHold:
32 """Place a bank under legal hold."""
33 key = f"{bank_id}{self._SEP}{hold_id}"
34 hold = LegalHold(
35 hold_id=hold_id,
36 bank_id=bank_id,
37 reason=reason,
38 set_at=datetime.now(timezone.utc),
39 set_by=set_by,
40 )
41 self._holds[key] = hold
42 return hold
44 def release_legal_hold(self, bank_id: str, hold_id: str) -> bool:
45 """Release a legal hold. Returns True if hold existed."""
46 key = f"{bank_id}{self._SEP}{hold_id}"
47 return self._holds.pop(key, None) is not None
49 def is_under_hold(self, bank_id: str) -> bool:
50 """Check if any legal hold is active on this bank."""
51 prefix = f"{bank_id}{self._SEP}"
52 return any(k.startswith(prefix) for k in self._holds)
54 def get_holds(self, bank_id: str) -> list[LegalHold]:
55 """Get all active holds for a bank."""
56 prefix = f"{bank_id}{self._SEP}"
57 return [h for k, h in self._holds.items() if k.startswith(prefix)]
59 def check_forget_allowed(self, bank_id: str) -> None:
60 """Raise LegalHoldActive if bank is under hold."""
61 holds = self.get_holds(bank_id)
62 if holds:
63 raise LegalHoldActive(bank_id=bank_id, hold_id=holds[0].hold_id)
65 # ── TTL evaluation (sync, pure) ──
67 def evaluate_memory_ttl(
68 self,
69 memory_id: str,
70 bank_id: str,
71 created_at: datetime | None,
72 last_recalled_at: datetime | None,
73 tags: list[str] | None,
74 fact_type: str | None,
75 now: datetime,
76 ) -> LifecycleAction:
77 """Evaluate TTL policy for a single memory. Sync, pure function.
79 Returns LifecycleAction with action="archive"|"delete"|"keep".
80 """
81 if not self._config.enabled:
82 return LifecycleAction(memory_id=memory_id, action="keep", reason="lifecycle_disabled")
84 # Legal hold blocks all lifecycle actions
85 if self.is_under_hold(bank_id):
86 return LifecycleAction(memory_id=memory_id, action="keep", reason="legal_hold")
88 ttl = self._config.ttl
90 # Check exempt tags
91 if tags and ttl.exempt_tags:
92 if any(t in ttl.exempt_tags for t in tags):
93 return LifecycleAction(memory_id=memory_id, action="keep", reason="exempt")
95 # Determine archive threshold (may be overridden by fact_type)
96 archive_days = ttl.archive_after_days
97 if fact_type and ttl.fact_type_overrides:
98 override = ttl.fact_type_overrides.get(fact_type)
99 if override is not None:
100 archive_days = override
102 # Check delete threshold (based on creation date)
103 if created_at:
104 age_days = (now - created_at).days
105 if age_days >= ttl.delete_after_days:
106 return LifecycleAction(memory_id=memory_id, action="delete", reason="ttl_expired")
108 # Check archive threshold (based on last recall or creation)
109 reference_date = last_recalled_at or created_at
110 if reference_date:
111 days_since = (now - reference_date).days
112 if days_since >= archive_days:
113 return LifecycleAction(memory_id=memory_id, action="archive", reason="ttl_unretrieved")
115 return LifecycleAction(memory_id=memory_id, action="keep", reason="recent")