superlocalmemory 4.0.4 → 4.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +23 -14
  3. package/ide/configs/codex-mcp.toml +2 -2
  4. package/package.json +3 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.mcp.json +1 -0
  7. package/plugin/CLAUDE.md +3 -3
  8. package/plugin/agents/slm-governance-advisor.md +1 -1
  9. package/plugin/agents/slm-loop-runner.md +1 -1
  10. package/plugin/agents/slm-memory-advisor.md +1 -1
  11. package/plugin/agents/slm-optimize-advisor.md +1 -1
  12. package/plugin/requirements.txt +1 -1
  13. package/plugin/skills/slm-cache/SKILL.md +1 -1
  14. package/plugin/skills/slm-compress/SKILL.md +1 -1
  15. package/plugin/skills/slm-governance/SKILL.md +1 -1
  16. package/plugin/skills/slm-graph/SKILL.md +3 -2
  17. package/plugin/skills/slm-loop/SKILL.md +1 -1
  18. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  19. package/plugin/skills/slm-profile/SKILL.md +2 -1
  20. package/plugin/skills/slm-recall/SKILL.md +1 -1
  21. package/plugin/skills/slm-remember/SKILL.md +1 -1
  22. package/plugin/skills/slm-scope/SKILL.md +1 -1
  23. package/plugin/skills/slm-session/SKILL.md +1 -1
  24. package/plugin/skills/slm-status/SKILL.md +1 -1
  25. package/plugin-src/rules/AGENTS.md +6 -5
  26. package/plugin-src/skills/slm-graph/SKILL.md +2 -1
  27. package/plugin-src/skills/slm-profile/SKILL.md +1 -0
  28. package/pyproject.toml +1 -1
  29. package/src/superlocalmemory/__init__.py +1 -1
  30. package/src/superlocalmemory/access/rbac.py +106 -0
  31. package/src/superlocalmemory/brain/__init__.py +5 -0
  32. package/src/superlocalmemory/brain/truth.py +418 -0
  33. package/src/superlocalmemory/cli/__main__.py +17 -0
  34. package/src/superlocalmemory/cli/commands.py +96 -28
  35. package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
  36. package/src/superlocalmemory/cli/gdpr_io.py +109 -0
  37. package/src/superlocalmemory/cli/main.py +88 -0
  38. package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
  39. package/src/superlocalmemory/code_graph/graph_store.py +180 -3
  40. package/src/superlocalmemory/code_graph/parser.py +280 -100
  41. package/src/superlocalmemory/compliance/gdpr.py +358 -0
  42. package/src/superlocalmemory/core/config.py +44 -1
  43. package/src/superlocalmemory/core/context_cache.py +58 -1
  44. package/src/superlocalmemory/core/engine_wiring.py +5 -1
  45. package/src/superlocalmemory/core/maintenance.py +43 -1
  46. package/src/superlocalmemory/core/mutations.py +155 -25
  47. package/src/superlocalmemory/core/recall_pipeline.py +6 -10
  48. package/src/superlocalmemory/core/recall_worker.py +33 -12
  49. package/src/superlocalmemory/core/remember_runtime.py +271 -2
  50. package/src/superlocalmemory/core/store_pipeline.py +100 -38
  51. package/src/superlocalmemory/encoding/consolidator.py +17 -47
  52. package/src/superlocalmemory/encoding/temporal_validator.py +14 -18
  53. package/src/superlocalmemory/hooks/user_prompt_hook.py +1 -1
  54. package/src/superlocalmemory/infra/backup.py +138 -0
  55. package/src/superlocalmemory/infra/backup_obligations.py +423 -0
  56. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +4 -3
  57. package/src/superlocalmemory/learning/engagement.py +165 -0
  58. package/src/superlocalmemory/mcp/profiles.py +19 -7
  59. package/src/superlocalmemory/mcp/server.py +4 -2
  60. package/src/superlocalmemory/mcp/tools_brain.py +54 -10
  61. package/src/superlocalmemory/mcp/tools_code_graph.py +31 -4
  62. package/src/superlocalmemory/mcp/tools_core.py +88 -3
  63. package/src/superlocalmemory/mcp/tools_v3.py +20 -6
  64. package/src/superlocalmemory/retrieval/engine.py +28 -10
  65. package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
  66. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +119 -19
  67. package/src/superlocalmemory/server/routes/brain.py +297 -14
  68. package/src/superlocalmemory/server/routes/learning.py +13 -25
  69. package/src/superlocalmemory/server/routes/memories.py +129 -3
  70. package/src/superlocalmemory/server/routes/v3_api.py +171 -60
  71. package/src/superlocalmemory/storage/_migration_internals.py +4 -0
  72. package/src/superlocalmemory/storage/_schema_version.py +2 -2
  73. package/src/superlocalmemory/storage/correction_cases.py +670 -0
  74. package/src/superlocalmemory/storage/database.py +230 -24
  75. package/src/superlocalmemory/storage/migration_runner.py +7 -0
  76. package/src/superlocalmemory/storage/migrations/M042_correction_case_ledger.py +245 -0
  77. package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
  78. package/src/superlocalmemory/storage/models.py +12 -4
  79. package/src/superlocalmemory/storage/write_coordinator.py +4 -0
  80. package/src/superlocalmemory/summaries/__init__.py +37 -0
  81. package/src/superlocalmemory/summaries/base.py +108 -0
  82. package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
  83. package/src/superlocalmemory/summaries/project_work_log.py +424 -0
  84. package/src/superlocalmemory/summaries/session_summary.py +307 -0
  85. package/src/superlocalmemory/ui/css/design-system.css +76 -1
  86. package/src/superlocalmemory/ui/index.html +28 -11
  87. package/src/superlocalmemory/ui/js/brain.js +43 -7
  88. package/src/superlocalmemory/ui/js/od-agents.js +49 -5
  89. package/src/superlocalmemory/ui/js/od-brain.js +280 -84
  90. package/src/superlocalmemory/ui/js/od-graph.js +147 -6
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
  import hashlib
15
15
  import json
16
16
  import logging
17
+ import sqlite3
17
18
  import uuid
18
19
  from typing import TYPE_CHECKING, Any
19
20
 
@@ -51,6 +52,72 @@ def _ingestion_effect_id(operation_id: str, *parts: object) -> str:
51
52
  return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
52
53
 
53
54
 
55
+ def _record_correction_candidate(
56
+ db: DatabaseManager,
57
+ *,
58
+ operation_id: str,
59
+ profile_id: str,
60
+ scope: str,
61
+ predecessor_fact_id: str,
62
+ successor_fact_id: str,
63
+ reason_code: str,
64
+ trusted_actor_id: str,
65
+ ) -> None:
66
+ """Append a review candidate through the current canonical transaction.
67
+
68
+ This intentionally carries identifiers and a controlled reason code only.
69
+ It must not use detector prose because that can contain user memory text.
70
+ If the current path is bound to the canonical coordinator,
71
+ ``raw_connection`` yields its already-open transaction; otherwise the
72
+ database manager owns the short transaction. Either path is atomic.
73
+ """
74
+ if not trusted_actor_id or not predecessor_fact_id or not successor_fact_id:
75
+ return
76
+ if predecessor_fact_id == successor_fact_id:
77
+ return
78
+ from superlocalmemory.storage.correction_cases import (
79
+ CorrectionActor,
80
+ CorrectionCaseError,
81
+ propose_on_connection,
82
+ )
83
+
84
+ case_id = _ingestion_effect_id(
85
+ operation_id, "correction-case", profile_id, predecessor_fact_id,
86
+ successor_fact_id, reason_code,
87
+ )
88
+ idempotency_key = _ingestion_effect_id(
89
+ operation_id, "correction-proposal", profile_id, predecessor_fact_id,
90
+ successor_fact_id, reason_code,
91
+ )
92
+ actor = CorrectionActor(
93
+ actor_id=trusted_actor_id,
94
+ actor_kind="host_attested",
95
+ trust_tier="canonical_writer",
96
+ )
97
+ try:
98
+ with db.raw_connection() as conn:
99
+ propose_on_connection(
100
+ conn,
101
+ case_id=case_id,
102
+ profile_id=profile_id,
103
+ scope=scope,
104
+ predecessor_fact_id=predecessor_fact_id,
105
+ successor_fact_id=successor_fact_id,
106
+ reason_code=reason_code,
107
+ actor=actor,
108
+ idempotency_key=idempotency_key,
109
+ # Candidate detection observes a possible correction. It does
110
+ # not claim an event-time boundary from heuristic evidence.
111
+ is_profile_active=lambda candidate_profile: candidate_profile == profile_id,
112
+ is_actor_trusted=lambda candidate_actor: candidate_actor == actor,
113
+ )
114
+ except (CorrectionCaseError, sqlite3.Error, ValueError) as exc:
115
+ # A missing/hot-upgrading M042 ledger must not make memory ingestion
116
+ # unavailable. The candidate is advisory and has no retrieval effect;
117
+ # the warning is the operational signal that an operator must inspect.
118
+ logger.warning("Correction candidate not recorded for %s: %s", successor_fact_id, exc)
119
+
120
+
54
121
  def _record_fact_entity_association(
55
122
  db: DatabaseManager,
56
123
  *,
@@ -731,47 +798,30 @@ def run_store(
731
798
  continue
732
799
  fact = existing_fact
733
800
 
734
- # Opinion confidence tracking: reinforce or decay
735
- if fact.fact_type == FactType.OPINION and action.action_type.value == "update":
736
- try:
737
- existing = db.get_fact(
738
- action.existing_fact_id or action.new_fact_id
739
- )
740
- if existing and existing.fact_type == FactType.OPINION:
741
- new_conf = min(1.0, existing.confidence + 0.1)
742
- db.update_fact(existing.fact_id, {"confidence": new_conf})
743
- except Exception:
744
- pass
745
- elif fact.fact_type == FactType.OPINION and action.action_type.value == "supersede":
746
- try:
747
- old_id = getattr(action, "old_fact_id", None)
748
- if old_id:
749
- old_fact = db.get_fact(old_id)
750
- if old_fact:
751
- new_conf = max(0.0, old_fact.confidence - 0.2)
752
- db.update_fact(old_id, {"confidence": new_conf})
753
- except Exception:
754
- pass
755
-
756
801
  if action.action_type.value in ("update", "supersede"):
757
- target_id = (
758
- (action.existing_fact_id or action.new_fact_id)
759
- if action.action_type.value == "update"
760
- else action.new_fact_id
761
- )
762
- if is_queryable_promotion and target_id != fact.fact_id:
763
- db.delete_fact(fact.fact_id)
764
- updated_fact = db.get_fact(target_id)
765
- if updated_fact is None:
802
+ # A consolidator UPDATE/SUPERSEDE is a review-required
803
+ # proposal. It persists the incoming successor and does
804
+ # not mutate, delete, archive, or change trust on the
805
+ # matched predecessor. Continue materializing the
806
+ # incoming fact through the common projection pipeline.
807
+ proposed_fact = db.get_fact(action.new_fact_id)
808
+ if proposed_fact is None:
766
809
  raise RuntimeError(
767
- f"consolidation {action.action_type.value} produced "
768
- f"missing fact {target_id}"
810
+ f"consolidation {action.action_type.value} proposal produced "
811
+ f"missing fact {action.new_fact_id}"
812
+ )
813
+ fact = proposed_fact
814
+ if action.existing_fact_id:
815
+ _record_correction_candidate(
816
+ db,
817
+ operation_id=ingestion_operation_id,
818
+ profile_id=profile_id,
819
+ scope=scope,
820
+ predecessor_fact_id=action.existing_fact_id,
821
+ successor_fact_id=action.new_fact_id,
822
+ reason_code=f"consolidation_{action.action_type.value}",
823
+ trusted_actor_id=trusted_actor_id,
769
824
  )
770
- # Continue through the shared index/graph/temporal/
771
- # provenance stages. The previous early continue made
772
- # UPDATE/SUPERSEDE facts look stored while skipping half of
773
- # canonical materialization.
774
- fact = updated_fact
775
825
  # ADD case: consolidator already stored the fact (F8 fix)
776
826
  # Fall through to post-processing below
777
827
  else:
@@ -860,6 +910,18 @@ def run_store(
860
910
  "Temporal: %d facts invalidated by new fact %s",
861
911
  len(invalidations), fact.fact_id,
862
912
  )
913
+ for candidate in invalidations:
914
+ predecessor_fact_id = str(candidate.get("old_fact_id") or "")
915
+ _record_correction_candidate(
916
+ db,
917
+ operation_id=ingestion_operation_id,
918
+ profile_id=profile_id,
919
+ scope=scope,
920
+ predecessor_fact_id=predecessor_fact_id,
921
+ successor_fact_id=fact.fact_id,
922
+ reason_code="temporal_contradiction",
923
+ trusted_actor_id=trusted_actor_id,
924
+ )
863
925
  except Exception as exc:
864
926
  temporal_complete = False
865
927
  logger.debug(
@@ -4,10 +4,13 @@
4
4
 
5
5
  """SuperLocalMemory V3 — Memory Consolidator.
6
6
 
7
- Mem0-style ADD/UPDATE/SUPERSEDE/NOOP logic for incoming facts.
8
- V1 was append-only (never updated, never deleted, never merged).
9
- This module gives a ~26% uplift by deduplicating, updating, and
10
- resolving contradictions at encoding time.
7
+ Mem0-style ADD/UPDATE/SUPERSEDE/NOOP classification for incoming facts.
8
+
9
+ UPDATE and SUPERSEDE are now *proposal classifications*: they persist the
10
+ incoming fact but never rewrite, archive, lower trust for, or otherwise mutate
11
+ the matched fact. A reviewed correction owner may later apply a case through
12
+ the correction ledger. This keeps ingestion useful while preventing an
13
+ automatic model judgement from changing the historical record.
11
14
 
12
15
  Mode A: keyword-based contradiction detection (zero LLM).
13
16
  Mode B/C: LLM-assisted contradiction detection when available.
@@ -20,7 +23,7 @@ from __future__ import annotations
20
23
 
21
24
  import logging
22
25
  import math
23
- from typing import Any, Protocol
26
+ from typing import Protocol
24
27
 
25
28
  from superlocalmemory.core.config import EncodingConfig
26
29
  from superlocalmemory.storage.database import DatabaseManager
@@ -30,7 +33,6 @@ from superlocalmemory.storage.models import (
30
33
  ConsolidationActionType,
31
34
  EdgeType,
32
35
  GraphEdge,
33
- MemoryLifecycle,
34
36
  )
35
37
 
36
38
  logger = logging.getLogger(__name__)
@@ -310,29 +312,17 @@ class MemoryConsolidator:
310
312
  *,
311
313
  reason: str,
312
314
  ) -> ConsolidationAction:
313
- """Update existing fact: bump evidence, optionally merge content."""
314
- new_evidence = existing.evidence_count + 1
315
- new_confidence = min(1.0, existing.confidence + 0.05)
316
- updates: dict[str, Any] = {
317
- "evidence_count": new_evidence,
318
- "confidence": new_confidence,
319
- }
320
-
321
- # If LLM available, merge content for a richer fact
322
- if self._llm is not None and self._llm.is_available():
323
- merged = self._merge_facts(existing.content, new_fact.content)
324
- if merged:
325
- updates["content"] = merged
326
-
327
- self._db.update_fact(existing.fact_id, updates)
315
+ """Persist a proposed refinement without rewriting the predecessor."""
316
+ self._db.store_fact(new_fact)
317
+ self._create_semantic_edges(new_fact, profile_id)
328
318
  action = self._log_action(
329
319
  ConsolidationActionType.UPDATE,
330
320
  new_fact.fact_id, existing.fact_id,
331
321
  profile_id, reason,
332
322
  )
333
323
  logger.debug(
334
- "UPDATE fact %s (evidence=%d): %s",
335
- existing.fact_id, new_evidence, reason,
324
+ "UPDATE proposal %s -> %s: %s",
325
+ existing.fact_id, new_fact.fact_id, reason,
336
326
  )
337
327
  return action
338
328
 
@@ -344,37 +334,17 @@ class MemoryConsolidator:
344
334
  *,
345
335
  reason: str,
346
336
  ) -> ConsolidationAction:
347
- """Archive old fact, store new, create contradiction edge."""
348
- # Archive old fact (keep for history but deprioritize in retrieval)
349
- self._db.update_fact(
350
- existing.fact_id,
351
- {"lifecycle": MemoryLifecycle.ARCHIVED},
352
- )
353
- # Store new fact
337
+ """Persist a proposed successor without changing the predecessor."""
354
338
  self._db.store_fact(new_fact)
355
- # Create contradiction + supersedes edges
356
- self._db.store_edge(GraphEdge(
357
- profile_id=profile_id,
358
- source_id=new_fact.fact_id,
359
- target_id=existing.fact_id,
360
- edge_type=EdgeType.CONTRADICTION,
361
- weight=1.0,
362
- ))
363
- self._db.store_edge(GraphEdge(
364
- profile_id=profile_id,
365
- source_id=new_fact.fact_id,
366
- target_id=existing.fact_id,
367
- edge_type=EdgeType.SUPERSEDES,
368
- weight=1.0,
369
- ))
339
+ self._create_semantic_edges(new_fact, profile_id)
370
340
  action = self._log_action(
371
341
  ConsolidationActionType.SUPERSEDE,
372
342
  new_fact.fact_id, existing.fact_id,
373
343
  profile_id, reason,
374
344
  )
375
345
  logger.debug(
376
- "SUPERSEDE %s %s: %s",
377
- new_fact.fact_id, existing.fact_id, reason,
346
+ "SUPERSEDE proposal %s -> %s: %s",
347
+ existing.fact_id, new_fact.fact_id, reason,
378
348
  )
379
349
  return action
380
350
 
@@ -2,7 +2,7 @@
2
2
  # Licensed under AGPL-3.0-or-later - see LICENSE file
3
3
  # Part of SuperLocalMemory V3
4
4
 
5
- """Temporal Intelligence -- contradiction detection and fact invalidation.
5
+ """Temporal Intelligence -- contradiction detection and reviewable proposals.
6
6
 
7
7
  Implements full bi-temporal validity tracking with 4 timestamps (L8 fix).
8
8
  Contradiction detection via sheaf cohomology (Mode A: pure math) or
@@ -36,7 +36,7 @@ logger = logging.getLogger(__name__)
36
36
 
37
37
 
38
38
  class TemporalValidator:
39
- """Validates temporal consistency and manages fact invalidation.
39
+ """Validates temporal consistency and proposes fact corrections.
40
40
 
41
41
  Components received via __init__ (NOT the engine -- Rule 06):
42
42
  - db: DatabaseManager
@@ -75,15 +75,20 @@ class TemporalValidator:
75
75
  new_fact: AtomicFact,
76
76
  profile_id: str,
77
77
  ) -> list[dict]:
78
- """Check new fact for contradictions and invalidate old facts.
78
+ """Check new fact for contradictions without mutating old facts.
79
79
 
80
80
  Algorithm:
81
81
  1. Detect contradictions (sheaf or LLM).
82
- 2. For each contradiction: invalidate old fact (set valid_until + system_expired_at).
83
- 3. Apply trust penalty to invalidated facts.
84
- 4. Return list of invalidation actions.
82
+ 2. Exclude valid historical progressions using explicit event anchors.
83
+ 3. Return review-required correction candidates.
85
84
 
86
- Returns list of dicts: {old_fact_id, new_fact_id, reason, severity}
85
+ This legacy method name is intentionally retained for API compatibility.
86
+ It no longer expires facts, changes trust, or changes retrieval state.
87
+ The reviewed-correction owner is the only layer permitted to apply a
88
+ candidate to bi-temporal validity.
89
+
90
+ Returns list of dicts: {old_fact_id, new_fact_id, reason, severity,
91
+ status="proposed"}.
87
92
  """
88
93
  contradictions = self.detect_contradiction(new_fact, profile_id)
89
94
 
@@ -108,25 +113,16 @@ class TemporalValidator:
108
113
  )
109
114
  continue
110
115
 
111
- # Step 1: Invalidate the old fact (bi-temporal)
112
- self.invalidate_fact(
113
- fact_id=old_fact_id,
114
- invalidated_by=new_fact.fact_id,
115
- reason=reason,
116
- )
117
-
118
- # Step 2: Apply trust penalty
119
- self._apply_trust_penalty(old_fact_id, profile_id)
120
-
121
116
  actions.append({
122
117
  "old_fact_id": old_fact_id,
123
118
  "new_fact_id": new_fact.fact_id,
124
119
  "reason": reason,
125
120
  "severity": severity,
121
+ "status": "proposed",
126
122
  })
127
123
 
128
124
  logger.info(
129
- "Temporal: invalidated %d facts due to new fact %s",
125
+ "Temporal: proposed %d correction(s) due to new fact %s",
130
126
  len(actions), new_fact.fact_id,
131
127
  )
132
128
  return actions
@@ -98,7 +98,7 @@ def main() -> int:
98
98
 
99
99
  try:
100
100
  topic_sig = compute_topic_signature(prompt, entity_hits=entity_hits)
101
- entry = read_entry_fast(session_id, topic_sig)
101
+ entry = read_entry_fast(session_id, topic_sig, require_current_admission=True)
102
102
  except Exception:
103
103
  sys.stdout.write("{}")
104
104
  return 0
@@ -25,6 +25,10 @@ from datetime import datetime, timedelta, timezone
25
25
  from pathlib import Path
26
26
  from typing import Dict, Generator, List, Optional
27
27
 
28
+ from superlocalmemory.infra.backup_obligations import (
29
+ BackupObligationStore,
30
+ erase_profile_from_snapshot,
31
+ )
28
32
  from superlocalmemory.infra.data_root import DynamicStatePath, canonical_data_root
29
33
 
30
34
  logger = logging.getLogger("superlocalmemory.backup")
@@ -387,6 +391,21 @@ class BackupCoordinator:
387
391
  # Phase C succeeded — remove pre-restore snapshots.
388
392
  self._cleanup_pre_restore_snapshots(pre_restore_map, pre_restore_lance)
389
393
 
394
+ # Phase D — GDPR obligation replay (invariant: no restore may resurrect
395
+ # erased personal data). Must run AFTER live files are in place and
396
+ # pre-restore snapshots are removed so there is no window where the
397
+ # erased data could be read. A replay failure raises immediately; the
398
+ # caller receives BackupRestoreError and must treat the restore as
399
+ # failed until an operator manually remediates.
400
+ if backup_set_dir is not None:
401
+ _replay_obligations_after_restore(
402
+ data_root=self._base_dir,
403
+ snapshot_key=str(backup_set_dir),
404
+ restored_db_paths=[
405
+ self._base_dir / e.store_name for e in manifest.stores
406
+ ],
407
+ )
408
+
390
409
  # ------------------------------------------------------------------
391
410
  # Internal helpers (factored out for subclass testability)
392
411
  # ------------------------------------------------------------------
@@ -727,6 +746,24 @@ class BackupManager:
727
746
  src.close()
728
747
 
729
748
  logger.info("Restored: %s -> %s", filename, target.name)
749
+
750
+ # GDPR obligation replay — prevent a restore from resurrecting
751
+ # previously erased personal data. Failure is fatal: return False
752
+ # so the caller knows the restore is not clean.
753
+ try:
754
+ _replay_obligations_after_restore(
755
+ data_root=self.db_path.parent,
756
+ snapshot_key=str(backup_path),
757
+ restored_db_paths=[target],
758
+ )
759
+ except BackupRestoreError as exc:
760
+ logger.error(
761
+ "Restore obligation replay failed for %s: %s — "
762
+ "restore is NOT clean; erased data may be present",
763
+ filename, exc,
764
+ )
765
+ return False
766
+
730
767
  return True
731
768
 
732
769
  except Exception as exc:
@@ -798,3 +835,104 @@ class BackupManager:
798
835
  "total_size_mb": round(sum(b["size_mb"] for b in backups), 2),
799
836
  "backups": backups,
800
837
  }
838
+
839
+
840
+ # ---------------------------------------------------------------------------
841
+ # GDPR obligation replay — module-level so both backup classes can call it
842
+ # ---------------------------------------------------------------------------
843
+
844
+
845
+ def _replay_obligations_after_restore(
846
+ data_root: Path,
847
+ snapshot_key: str,
848
+ restored_db_paths: list[Path],
849
+ ) -> None:
850
+ """Re-apply pending erasure obligations to freshly restored database files.
851
+
852
+ This function is the enforcement point for the restore-replay invariant:
853
+ no restore may resurface data that was Art.17-erased from the live stores.
854
+
855
+ ``snapshot_key`` is the string path that was recorded as the obligation's
856
+ ``snapshot_path`` when the obligation was created (typically the backup-set
857
+ directory for new-style backups, or the per-file `.db` path for legacy
858
+ backups).
859
+
860
+ Raises:
861
+ BackupRestoreError: if any obligation cannot be replayed. The caller
862
+ must treat the restore as failed and surface this to the operator.
863
+ """
864
+ store = BackupObligationStore(data_root)
865
+ obligations = store.list_pending_for_snapshot(snapshot_key)
866
+
867
+ # Belt-and-suspenders: even when path matching fails (e.g. backup moved),
868
+ # detect erased profiles by scanning the restored DB and checking whether
869
+ # any of the profiles present have pending obligations.
870
+ if not obligations:
871
+ for db_path in restored_db_paths:
872
+ if not db_path.exists():
873
+ continue
874
+ try:
875
+ import sqlite3 as _sq3
876
+ conn = _sq3.connect(str(db_path))
877
+ try:
878
+ tables = {
879
+ r[0] for r in
880
+ conn.execute(
881
+ "SELECT name FROM sqlite_master WHERE type='table'"
882
+ ).fetchall()
883
+ }
884
+ for tbl in ("profiles", "atomic_facts"):
885
+ if tbl not in tables:
886
+ continue
887
+ cols = {
888
+ r[1] for r in
889
+ conn.execute(f"PRAGMA table_info({tbl})").fetchall()
890
+ }
891
+ if "profile_id" not in cols:
892
+ continue
893
+ for (pid,) in conn.execute(
894
+ f"SELECT DISTINCT profile_id FROM {tbl}"
895
+ ).fetchall():
896
+ if pid:
897
+ obligations.extend(
898
+ store.list_pending_for_profile(pid)
899
+ )
900
+ finally:
901
+ conn.close()
902
+ except Exception as exc: # noqa: BLE001
903
+ logger.warning(
904
+ "_replay_obligations_after_restore: scan failed for %s: %s",
905
+ db_path.name, exc,
906
+ )
907
+
908
+ if not obligations:
909
+ return
910
+
911
+ profile_ids = {o["profile_id"] for o in obligations}
912
+
913
+ for profile_id in profile_ids:
914
+ for db_path in restored_db_paths:
915
+ if not db_path.exists():
916
+ continue
917
+ try:
918
+ deleted = erase_profile_from_snapshot(db_path, profile_id)
919
+ if deleted:
920
+ logger.info(
921
+ "Restore replay: erased profile %r from %s: %s",
922
+ profile_id, db_path.name, deleted,
923
+ )
924
+ except Exception as exc: # noqa: BLE001
925
+ raise BackupRestoreError(
926
+ f"Erasure obligation replay failed for profile {profile_id!r} "
927
+ f"in {db_path.name}: {exc}. "
928
+ "The restored database may contain previously erased personal "
929
+ "data. Manual remediation required."
930
+ ) from exc
931
+
932
+ # Discharge obligations for this specific snapshot so they do not block
933
+ # the completeness flag for future erasures of the same profile.
934
+ discharged = store.discharge_for_snapshot(snapshot_key, "replayed_on_restore")
935
+ logger.info(
936
+ "Obligation replay: discharged %d obligations for snapshot %s",
937
+ discharged, snapshot_key,
938
+ )