superlocalmemory 3.8.13 → 4.0.0

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 (212) hide show
  1. package/ATTRIBUTION.md +4 -4
  2. package/CHANGELOG.md +113 -121
  3. package/README.md +65 -63
  4. package/docs/pi-dev-integration.md +1 -1
  5. package/package.json +6 -1
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  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 +1 -1
  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 +1 -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 +1 -1
  26. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-governance/SKILL.md +248 -0
  29. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-loop/SKILL.md +99 -0
  31. package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
  32. package/plugin-src/skills/slm-profile/SKILL.md +148 -0
  33. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-scope/SKILL.md +176 -0
  36. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  37. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  38. package/pyproject.toml +11 -4
  39. package/src/superlocalmemory/__init__.py +1 -1
  40. package/src/superlocalmemory/cli/commands.py +125 -11
  41. package/src/superlocalmemory/cli/daemon.py +5 -1
  42. package/src/superlocalmemory/cli/main.py +35 -2
  43. package/src/superlocalmemory/cli/ops_cmd.py +281 -0
  44. package/src/superlocalmemory/cli/setup_wizard.py +1 -1
  45. package/src/superlocalmemory/compliance/audit.py +65 -0
  46. package/src/superlocalmemory/compliance/eu_ai_act.py +27 -57
  47. package/src/superlocalmemory/compliance/gdpr.py +416 -20
  48. package/src/superlocalmemory/compliance/retention.py +74 -22
  49. package/src/superlocalmemory/compliance/scheduler.py +78 -9
  50. package/src/superlocalmemory/core/actor_context.py +166 -0
  51. package/src/superlocalmemory/core/admission.py +549 -0
  52. package/src/superlocalmemory/core/backend_orchestrator.py +23 -10
  53. package/src/superlocalmemory/core/config.py +202 -24
  54. package/src/superlocalmemory/core/consolidation_engine.py +13 -13
  55. package/src/superlocalmemory/core/context_cache.py +28 -0
  56. package/src/superlocalmemory/core/embeddings.py +64 -2
  57. package/src/superlocalmemory/core/engine.py +7 -2
  58. package/src/superlocalmemory/core/engine_ingestion.py +65 -3
  59. package/src/superlocalmemory/core/engine_wiring.py +36 -9
  60. package/src/superlocalmemory/core/ingest_policy.py +38 -0
  61. package/src/superlocalmemory/core/maintenance.py +255 -0
  62. package/src/superlocalmemory/core/modes.py +40 -13
  63. package/src/superlocalmemory/core/mutations.py +437 -44
  64. package/src/superlocalmemory/core/operation_policy.py +92 -0
  65. package/src/superlocalmemory/core/operation_policy_registry.py +542 -0
  66. package/src/superlocalmemory/core/operation_request.py +127 -0
  67. package/src/superlocalmemory/core/ops_remediation.py +542 -0
  68. package/src/superlocalmemory/core/recall_pipeline.py +7 -0
  69. package/src/superlocalmemory/core/remember_runtime.py +202 -4
  70. package/src/superlocalmemory/core/remote_mode.py +20 -5
  71. package/src/superlocalmemory/core/store_pipeline.py +150 -0
  72. package/src/superlocalmemory/core/topic_signature.py +19 -4
  73. package/src/superlocalmemory/core/transactions/__init__.py +78 -0
  74. package/src/superlocalmemory/core/transactions/concrete_owners.py +597 -0
  75. package/src/superlocalmemory/core/transactions/erasure.py +825 -0
  76. package/src/superlocalmemory/core/transactions/manifest.py +255 -0
  77. package/src/superlocalmemory/core/transactions/manifest_key.py +155 -0
  78. package/src/superlocalmemory/core/transactions/obligations.py +272 -0
  79. package/src/superlocalmemory/core/transactions/owners.py +114 -0
  80. package/src/superlocalmemory/core/transactions/reconciler.py +285 -0
  81. package/src/superlocalmemory/core/transactions/service.py +330 -0
  82. package/src/superlocalmemory/core/worker_pool.py +33 -5
  83. package/src/superlocalmemory/encoding/cognitive_consolidator.py +70 -28
  84. package/src/superlocalmemory/encoding/emotional.py +75 -14
  85. package/src/superlocalmemory/encoding/scene_builder.py +115 -13
  86. package/src/superlocalmemory/encoding/temporal_parser.py +4 -0
  87. package/src/superlocalmemory/evolution/blind_verifier.py +11 -4
  88. package/src/superlocalmemory/evolution/evolution_store.py +244 -4
  89. package/src/superlocalmemory/evolution/llm_dispatch.py +40 -0
  90. package/src/superlocalmemory/evolution/model_selection.py +18 -3
  91. package/src/superlocalmemory/evolution/mutation_generator.py +3 -0
  92. package/src/superlocalmemory/evolution/skill_activator.py +270 -0
  93. package/src/superlocalmemory/evolution/skill_evolver.py +281 -59
  94. package/src/superlocalmemory/evolution/types.py +30 -8
  95. package/src/superlocalmemory/graph/cozo_backend.py +17 -9
  96. package/src/superlocalmemory/hooks/auto_invoker.py +2 -1
  97. package/src/superlocalmemory/hooks/auto_recall.py +64 -30
  98. package/src/superlocalmemory/hooks/codex_assets.py +14 -1
  99. package/src/superlocalmemory/infra/backup.py +434 -7
  100. package/src/superlocalmemory/infra/process_reaper.py +18 -0
  101. package/src/superlocalmemory/infra/self_heal.py +401 -0
  102. package/src/superlocalmemory/learning/feedback.py +52 -9
  103. package/src/superlocalmemory/loops/engine.py +10 -0
  104. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  105. package/src/superlocalmemory/mcp/http_transport.py +30 -331
  106. package/src/superlocalmemory/mcp/profiles.py +5 -0
  107. package/src/superlocalmemory/mcp/resources.py +8 -0
  108. package/src/superlocalmemory/mcp/server.py +51 -4
  109. package/src/superlocalmemory/mcp/shared.py +19 -0
  110. package/src/superlocalmemory/mcp/tools_active.py +25 -4
  111. package/src/superlocalmemory/mcp/tools_code_graph.py +26 -18
  112. package/src/superlocalmemory/mcp/tools_context.py +50 -8
  113. package/src/superlocalmemory/mcp/tools_core.py +69 -21
  114. package/src/superlocalmemory/mcp/tools_evolution.py +9 -2
  115. package/src/superlocalmemory/mcp/tools_learning.py +21 -10
  116. package/src/superlocalmemory/mcp/tools_loops.py +29 -18
  117. package/src/superlocalmemory/mcp/tools_mesh.py +8 -0
  118. package/src/superlocalmemory/mcp/tools_ops.py +115 -0
  119. package/src/superlocalmemory/mcp/tools_optimize.py +4 -0
  120. package/src/superlocalmemory/mcp/tools_v28.py +10 -3
  121. package/src/superlocalmemory/mcp/tools_v3.py +34 -14
  122. package/src/superlocalmemory/mcp/tools_v33.py +18 -33
  123. package/src/superlocalmemory/mesh/broker.py +124 -46
  124. package/src/superlocalmemory/mesh/broker_security.py +470 -0
  125. package/src/superlocalmemory/mesh/discovery.py +365 -0
  126. package/src/superlocalmemory/mesh/lock_protocol.py +313 -0
  127. package/src/superlocalmemory/mesh/node_identity.py +97 -0
  128. package/src/superlocalmemory/mesh/outbox_remote.py +429 -0
  129. package/src/superlocalmemory/mesh/remote_sync.py +511 -28
  130. package/src/superlocalmemory/mesh/state_sync.py +286 -0
  131. package/src/superlocalmemory/optimize/config/store.py +45 -0
  132. package/src/superlocalmemory/parameterization/cross_project.py +12 -0
  133. package/src/superlocalmemory/parameterization/prompt_injector.py +13 -11
  134. package/src/superlocalmemory/parameterization/prompt_lifecycle.py +8 -2
  135. package/src/superlocalmemory/parameterization/workflow_miner.py +17 -0
  136. package/src/superlocalmemory/retrieval/ann_index.py +5 -0
  137. package/src/superlocalmemory/retrieval/bm25_channel.py +49 -2
  138. package/src/superlocalmemory/retrieval/engine.py +19 -4
  139. package/src/superlocalmemory/retrieval/fusion.py +4 -1
  140. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -3
  141. package/src/superlocalmemory/retrieval/remote_reranker.py +47 -22
  142. package/src/superlocalmemory/retrieval/reranker.py +32 -1
  143. package/src/superlocalmemory/retrieval/temporal_channel.py +16 -3
  144. package/src/superlocalmemory/retrieval/temporal_utils.py +107 -0
  145. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +155 -42
  146. package/src/superlocalmemory/retrieval/vector_store.py +214 -8
  147. package/src/superlocalmemory/server/api.py +5 -5
  148. package/src/superlocalmemory/server/egress_policy.py +258 -0
  149. package/src/superlocalmemory/server/rbac_enforce.py +32 -0
  150. package/src/superlocalmemory/server/route_mutations.py +20 -0
  151. package/src/superlocalmemory/server/routes/compliance.py +153 -7
  152. package/src/superlocalmemory/server/routes/data_io.py +43 -2
  153. package/src/superlocalmemory/server/routes/events.py +15 -0
  154. package/src/superlocalmemory/server/routes/memories.py +56 -3
  155. package/src/superlocalmemory/server/routes/mesh.py +82 -1
  156. package/src/superlocalmemory/server/routes/mesh_lock.py +54 -0
  157. package/src/superlocalmemory/server/routes/mesh_state.py +63 -0
  158. package/src/superlocalmemory/server/routes/v3_api.py +50 -27
  159. package/src/superlocalmemory/server/routes/ws.py +86 -0
  160. package/src/superlocalmemory/server/ui.py +6 -6
  161. package/src/superlocalmemory/server/unified_daemon.py +942 -119
  162. package/src/superlocalmemory/storage/_migration_internals.py +568 -0
  163. package/src/superlocalmemory/storage/_schema_version.py +110 -0
  164. package/src/superlocalmemory/storage/database.py +329 -24
  165. package/src/superlocalmemory/storage/embedding_migrator.py +246 -51
  166. package/src/superlocalmemory/storage/erasure_fence.py +45 -0
  167. package/src/superlocalmemory/storage/generation_fence.py +63 -0
  168. package/src/superlocalmemory/storage/migration_runner.py +140 -417
  169. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +40 -0
  170. package/src/superlocalmemory/storage/migrations/M033_projection_transactions.py +148 -0
  171. package/src/superlocalmemory/storage/migrations/M034_obligation_integrity.py +58 -0
  172. package/src/superlocalmemory/storage/migrations/M035_erasure_receipts.py +113 -0
  173. package/src/superlocalmemory/storage/migrations/M036_vector_row_map.py +107 -0
  174. package/src/superlocalmemory/storage/migrations/M037_manifest_hmac_version.py +162 -0
  175. package/src/superlocalmemory/storage/migrations/{M033_learning_feedback_channel.py → M038_learning_feedback_channel.py} +3 -3
  176. package/src/superlocalmemory/storage/migrations/M039_scene_fact_members.py +137 -0
  177. package/src/superlocalmemory/storage/migrations/__init__.py +4 -2
  178. package/src/superlocalmemory/storage/schema.py +67 -0
  179. package/src/superlocalmemory/storage/write_coordinator.py +125 -0
  180. package/src/superlocalmemory/trust/scorer.py +28 -4
  181. package/src/superlocalmemory/ui/index.html +14 -3
  182. package/src/superlocalmemory/ui/js/auto-settings.js +12 -1
  183. package/src/superlocalmemory/ui/js/brain.js +6 -4
  184. package/src/superlocalmemory/ui/js/compliance.js +66 -12
  185. package/src/superlocalmemory/ui/js/dashboard.js +13 -3
  186. package/src/superlocalmemory/ui/js/feedback.js +8 -2
  187. package/src/superlocalmemory/ui/js/lifecycle.js +7 -1
  188. package/src/superlocalmemory/ui/js/modal.js +272 -5
  189. package/src/superlocalmemory/ui/js/od-backup.js +9 -2
  190. package/src/superlocalmemory/ui/js/od-compliance-ext.js +301 -0
  191. package/src/superlocalmemory/ui/js/od-operations.js +154 -23
  192. package/src/superlocalmemory/ui/js/od-ops-health.js +417 -0
  193. package/src/superlocalmemory/ui/js/od-optimize.js +35 -21
  194. package/src/superlocalmemory/ui/js/od-team.js +9 -2
  195. package/src/superlocalmemory/ui/js/optimize.js +13 -16
  196. package/src/superlocalmemory/ui/js/profiles.js +7 -3
  197. package/src/superlocalmemory/ui/js/settings.js +7 -1
  198. package/src/superlocalmemory/vector/lancedb_backend.py +19 -9
  199. package/src/superlocalmemory/attribution/mathematical_dna.py +0 -235
  200. package/src/superlocalmemory/cli/post_install.py +0 -114
  201. package/src/superlocalmemory/core/clock_monitor.py +0 -45
  202. package/src/superlocalmemory/core/db_pool.py +0 -80
  203. package/src/superlocalmemory/core/error_catalog.py +0 -113
  204. package/src/superlocalmemory/core/loop_watchdog.py +0 -56
  205. package/src/superlocalmemory/core/priority_queue.py +0 -61
  206. package/src/superlocalmemory/core/pruning_engine.py +0 -216
  207. package/src/superlocalmemory/core/queue_dispatcher.py +0 -73
  208. package/src/superlocalmemory/core/slmignore.py +0 -125
  209. package/src/superlocalmemory/infra/heartbeat_monitor.py +0 -140
  210. package/src/superlocalmemory/infra/webhook_dispatcher.py +0 -247
  211. package/src/superlocalmemory/learning/quantization_scheduler.py +0 -320
  212. package/src/superlocalmemory/storage/access_control.py +0 -182
@@ -13,9 +13,9 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
- import json
17
16
  import logging
18
17
  from datetime import UTC, datetime
18
+ from pathlib import Path
19
19
 
20
20
  logger = logging.getLogger(__name__)
21
21
 
@@ -40,12 +40,150 @@ class GDPRCompliance:
40
40
  """
41
41
 
42
42
  # Tables that carry a profile_id column but are NOT tenant memory to be
43
- # erased/exported wholesale. `profiles` is the tenant record (handled
44
- # separately, deleted last).
45
- _NON_MEMORY_SCOPED = frozenset({"profiles"})
43
+ # erased/exported wholesale.
44
+ # `profiles` — the tenant record (handled separately, deleted last).
45
+ # `erasure_receipts` — tamper-evident audit chain for Art.17 erasure events;
46
+ # must survive the profile wipe so operators can prove deletion occurred.
47
+ _NON_MEMORY_SCOPED = frozenset({"profiles", "erasure_receipts"})
46
48
 
47
- def __init__(self, db) -> None:
49
+ def __init__(self, db, *, engine=None, data_root: str | Path | None = None) -> None:
48
50
  self._db = db
51
+ self._engine = engine
52
+ self._data_root = Path(data_root).resolve() if data_root is not None else None
53
+
54
+ def _memory_has_siblings(self, memory_id: str, profile_id: str) -> bool:
55
+ try:
56
+ return bool(self._db.execute(
57
+ "SELECT 1 FROM atomic_facts "
58
+ "WHERE memory_id = ? AND profile_id = ? LIMIT 1",
59
+ (memory_id, profile_id),
60
+ ))
61
+ except Exception:
62
+ return True
63
+
64
+ def _tombstone(self, fact_id: str, profile_id: str, memory_id: str | None) -> None:
65
+ try:
66
+ import time
67
+ import uuid
68
+
69
+ from superlocalmemory.core.transactions.erasure import write_tombstones
70
+
71
+ write_tombstones(
72
+ self._db, profile_id, (fact_id,), uuid.uuid4().hex,
73
+ time.time(), memory_id,
74
+ )
75
+ except Exception:
76
+ pass
77
+
78
+ def _purge_fact_projections(self, fact_id: str, profile_id: str) -> None:
79
+ try:
80
+ self._db.delete_bm25_tokens_for_fact(fact_id)
81
+ except Exception:
82
+ pass
83
+ engine = self._engine
84
+ if engine is None:
85
+ return
86
+ store = getattr(engine, "_vector_store", None)
87
+ ann = getattr(engine, "_ann_index", None)
88
+ if store is not None and getattr(store, "available", False):
89
+ try:
90
+ store.delete(fact_id)
91
+ except Exception:
92
+ pass
93
+ if ann is not None and hasattr(ann, "remove"):
94
+ try:
95
+ ann.remove(fact_id)
96
+ except Exception:
97
+ pass
98
+
99
+ def _purge_vector_and_ann(self, profile_id: str) -> tuple[int, int]:
100
+ engine = self._engine
101
+ if engine is None:
102
+ return 0, 0
103
+ store = getattr(engine, "_vector_store", None)
104
+ ann = getattr(engine, "_ann_index", None)
105
+
106
+ purged = 0
107
+ failures = 0
108
+
109
+ try:
110
+ db_fact_ids = [
111
+ dict(r)["fact_id"]
112
+ for r in self._db.execute(
113
+ "SELECT fact_id FROM atomic_facts WHERE profile_id = ?",
114
+ (profile_id,),
115
+ )
116
+ ]
117
+ except Exception as exc:
118
+ logger.warning("GDPR erase: fact_id enumeration failed: %s", exc)
119
+ db_fact_ids = []
120
+ failures += 1
121
+
122
+ store_available = store is not None and getattr(store, "available", False)
123
+ store_fact_ids: list[str] = []
124
+ if store_available:
125
+ try:
126
+ store_fact_ids = list(store.indexed_fact_ids(profile_id))
127
+ except Exception as exc:
128
+ logger.warning("GDPR erase: vector enumeration failed: %s", exc)
129
+ failures += 1
130
+ store_fact_ids = list(db_fact_ids)
131
+ for fid in store_fact_ids:
132
+ try:
133
+ if store.delete(fid):
134
+ purged += 1
135
+ else:
136
+ failures += 1
137
+ except Exception as exc:
138
+ logger.warning("GDPR erase: vector delete failed for %s: %s", fid, exc)
139
+ failures += 1
140
+ else:
141
+ # No usable vector backend: raw vec0/map payload cannot be removed.
142
+ # Count residual raw vectors as failures so the receipt cannot claim
143
+ # a complete erasure while physical vectors survive.
144
+ residue = self._count_vector_residue(profile_id)
145
+ if residue:
146
+ failures += residue
147
+
148
+ if ann is not None and hasattr(ann, "remove"):
149
+ all_to_purge = set(store_fact_ids) | set(db_fact_ids)
150
+ for fid in all_to_purge:
151
+ try:
152
+ ann.remove(fid)
153
+ except Exception as exc:
154
+ logger.warning("GDPR erase: ANN remove failed for %s: %s", fid, exc)
155
+
156
+ return purged, failures
157
+
158
+ def _count_vector_residue(self, profile_id: str) -> int:
159
+ total = 0
160
+ for table in ("vector_row_map", "embedding_metadata"):
161
+ try:
162
+ rows = self._db.execute(
163
+ f"SELECT COUNT(*) AS c FROM {table} WHERE profile_id = ?",
164
+ (profile_id,),
165
+ )
166
+ total = max(total, int(dict(rows[0])["c"]) if rows else 0)
167
+ except Exception:
168
+ continue
169
+ return total
170
+
171
+ def _fact_vector_residue(self, profile_id: str, fact_ids: list[str]) -> int:
172
+ if not fact_ids:
173
+ return 0
174
+ residue: set[str] = set()
175
+ placeholders = ",".join("?" for _ in fact_ids)
176
+ for table in ("vector_row_map", "embedding_metadata"):
177
+ try:
178
+ rows = self._db.execute(
179
+ f"SELECT fact_id FROM {table} "
180
+ f"WHERE profile_id = ? AND fact_id IN ({placeholders})",
181
+ (profile_id, *fact_ids),
182
+ )
183
+ residue |= {dict(r)["fact_id"] for r in rows}
184
+ except Exception:
185
+ continue
186
+ return len(residue)
49
187
 
50
188
  def _profile_scoped_tables(self) -> list[str]:
51
189
  """Every table carrying a ``profile_id`` column — discovered live from
@@ -129,7 +267,12 @@ class GDPRCompliance:
129
267
  raise ValueError("Cannot delete the default profile via GDPR erasure. "
130
268
  "Use profile deletion instead.")
131
269
 
132
- # 1) Durable, tamper-evident record FIRST — survives the erasure.
270
+ counts: dict[str, int] = {}
271
+
272
+ # 1) Durable, tamper-evident record FIRST — a HARD precondition
273
+ # (Art. 5(2) accountability). If it cannot be written we fail closed
274
+ # and delete nothing, so no erasure ever occurs without an
275
+ # accountability record.
133
276
  try:
134
277
  from superlocalmemory.compliance.audit import AuditChain
135
278
  from superlocalmemory.infra.data_root import state_path
@@ -138,10 +281,14 @@ class GDPRCompliance:
138
281
  metadata={"basis": "GDPR Art.17 right-to-erasure"},
139
282
  )
140
283
  except Exception as exc:
141
- logger.warning("GDPR erase: audit-chain log failed: %s", exc)
284
+ logger.error(
285
+ "GDPR erase ABORTED for %r: pre-deletion audit-chain log failed: %s",
286
+ profile_id, exc,
287
+ )
288
+ counts["audit_request_failed"] = 1
289
+ counts["erasure_aborted"] = 1
290
+ return counts
142
291
  self._audit("delete", "profile", profile_id, "GDPR erasure request")
143
-
144
- counts: dict[str, int] = {}
145
292
  tables = self._profile_scoped_tables()
146
293
  # Pass 1 — count every table BEFORE any deletion, so a CASCADE that
147
294
  # removes a child (e.g. atomic_facts via memories) does not zero the
@@ -156,6 +303,135 @@ class GDPRCompliance:
156
303
  except Exception as exc: # pragma: no cover
157
304
  logger.warning("GDPR erase: count %s failed: %s", table, exc)
158
305
  counts[table] = 0
306
+
307
+ # Purge context-cache entries BEFORE main-DB row deletions.
308
+ #
309
+ # Crash-recovery rationale: the cache and the main DB live in separate
310
+ # SQLite files — they cannot share one ACID transaction. Ordering the
311
+ # cache purge first ensures that any crash between the two steps leaves
312
+ # the profile record still present in the main DB, so a retry of
313
+ # forget_profile re-runs the full sequence and completes safely. The
314
+ # reverse order (cache after main delete) would orphan cache PII in a
315
+ # state that no retry can reach.
316
+ #
317
+ # The cache DB lives under the data root (same directory as the main DB)
318
+ # or in an immediate subdirectory. Scan both levels to cover the default
319
+ # layout and any explicitly-namespaced cache dirs.
320
+ #
321
+ # Destructive sidecar erasure requires an authoritative root. Never
322
+ # fall back to a process-global default, which might be another SLM
323
+ # installation.
324
+ data_root = self._data_root
325
+ try:
326
+ from superlocalmemory.core.context_cache import purge_profile_from_cache_db
327
+ if data_root is None:
328
+ db_path = getattr(self._db, "db_path", None)
329
+ if db_path is not None:
330
+ data_root = Path(db_path).resolve().parent
331
+
332
+ if data_root is None:
333
+ logger.warning(
334
+ "GDPR erase: context-cache purge skipped for profile %r — "
335
+ "data root could not be resolved; pass data_root explicitly "
336
+ "for custom DB wrappers.",
337
+ profile_id,
338
+ )
339
+
340
+ if data_root is not None:
341
+ cache_name = "active_brain_cache.db"
342
+ candidates: list = [data_root / cache_name]
343
+ try:
344
+ for child in data_root.iterdir():
345
+ if child.is_dir():
346
+ candidates.append(child / cache_name)
347
+ except Exception:
348
+ pass
349
+ cache_purged = 0
350
+ for candidate in candidates:
351
+ cache_purged += purge_profile_from_cache_db(candidate, profile_id)
352
+ if cache_purged:
353
+ counts["context_cache"] = cache_purged
354
+ except Exception as exc:
355
+ # Fail-closed: a context-cache purge failure must not be silently
356
+ # tolerated — it can leave profile PII in the cache DB.
357
+ logger.warning("GDPR erase: context-cache purge failed: %s", exc)
358
+ counts["context_cache_failed"] = 1
359
+
360
+ try:
361
+ vector_purged, vector_failures = self._purge_vector_and_ann(profile_id)
362
+ counts["vector_store"] = vector_purged
363
+ if vector_failures:
364
+ counts["vector_store_failures"] = vector_failures
365
+ except Exception as exc:
366
+ # Fail-closed: a top-level vector-purge exception (as opposed to the
367
+ # per-fact failures returned in vector_failures) must set an explicit
368
+ # marker, or erasure_complete could still report 1 despite the vector
369
+ # projection never being purged.
370
+ logger.warning("GDPR erase: vector purge failed: %s", exc)
371
+ counts["vector_store_failures"] = counts.get("vector_store_failures", 0) or 1
372
+
373
+ # Erasure receipt (P1-5) — route the profile wipe through ErasureService
374
+ # so the receipt captures real per-owner proofs (not proofs:[]).
375
+ #
376
+ # erasure_receipts is in _NON_MEMORY_SCOPED so Pass 2 does NOT delete
377
+ # the receipt — it survives as the tamper-evident Art.17 audit chain.
378
+ # remove() + finalize() therefore run here, before Pass 2, while
379
+ # atomic_facts is still queryable for embedding presence checks.
380
+ #
381
+ # Wrapped in try-except so a missing M033/M035 schema never blocks the
382
+ # Art.17 right-to-erasure.
383
+ import time as _time
384
+ import uuid as _uuid
385
+
386
+ _profile_fact_ids: tuple[str, ...] = ()
387
+ try:
388
+ _fact_rows = self._db.execute(
389
+ "SELECT fact_id FROM atomic_facts WHERE profile_id = ?",
390
+ (profile_id,),
391
+ )
392
+ _profile_fact_ids = tuple(sorted(
393
+ dict(r)["fact_id"] for r in _fact_rows
394
+ if dict(r).get("fact_id") is not None
395
+ ))
396
+ except Exception as exc:
397
+ logger.warning("GDPR profile erase: fact_id scan failed: %s", exc)
398
+
399
+ # Always write an erasure receipt — even for empty profiles (fact_ids=()).
400
+ # Skipping the receipt for no-fact profiles left an Art.17 accountability
401
+ # gap: a destructive wipe with no durable audit record. ErasureService
402
+ # handles empty fact_ids safely (all owners vacuously return erased=True).
403
+ # If finalize() raises (e.g. signing-key unavailable), propagate — we must
404
+ # not silently proceed with a wipe that has no accountability record.
405
+ try:
406
+ from superlocalmemory.core.transactions.concrete_owners import (
407
+ build_erasure_service_for_db,
408
+ )
409
+ from superlocalmemory.core.transactions.owners import OperationContext
410
+
411
+ _erasure_svc = build_erasure_service_for_db(self._db, self._engine)
412
+ _ctx = OperationContext(
413
+ operation_id=_uuid.uuid4().hex,
414
+ profile_id=profile_id,
415
+ subject_id=profile_id,
416
+ fact_ids=_profile_fact_ids,
417
+ )
418
+ _remove_result = _erasure_svc.remove(self._db, _ctx)
419
+ _receipt = _erasure_svc.finalize(
420
+ self._db, _ctx,
421
+ subject_type="profile",
422
+ subject_id=profile_id,
423
+ requested_by="gdpr",
424
+ requested_at=_time.time(),
425
+ remove_result=_remove_result,
426
+ )
427
+ if not _receipt.persisted:
428
+ counts["receipt_persist_failed"] = 1
429
+ if not _receipt.all_erased:
430
+ counts["owner_erasure_incomplete"] = 1
431
+ except Exception as exc:
432
+ counts["receipt_error"] = str(exc)
433
+ raise
434
+
159
435
  # Pass 2 — full-tenant wipe with FK enforcement OFF so table order is
160
436
  # irrelevant (every profile row in every table goes). FTS shadow rows
161
437
  # are still removed by the base-table delete triggers.
@@ -163,6 +439,7 @@ class GDPRCompliance:
163
439
  self._db.execute("PRAGMA foreign_keys=OFF")
164
440
  except Exception:
165
441
  pass
442
+ table_delete_failures: list[str] = []
166
443
  try:
167
444
  for table in tables:
168
445
  try:
@@ -171,6 +448,7 @@ class GDPRCompliance:
171
448
  )
172
449
  except Exception as exc: # pragma: no cover — defensive per-table
173
450
  logger.warning("GDPR erase: delete %s failed: %s", table, exc)
451
+ table_delete_failures.append(table)
174
452
  # Delete the profile record itself.
175
453
  self._db.execute("DELETE FROM profiles WHERE profile_id = ?", (profile_id,))
176
454
  counts["profiles"] = 1
@@ -179,16 +457,23 @@ class GDPRCompliance:
179
457
  self._db.execute("PRAGMA foreign_keys=ON")
180
458
  except Exception:
181
459
  pass
460
+ if table_delete_failures:
461
+ counts["table_delete_failures"] = len(table_delete_failures)
182
462
 
183
- # Erase learning database (separate DB file)
463
+ # Erase the learning sidecar next to the active memory database. A
464
+ # custom SLM data root must never fall back to another installation's
465
+ # DEFAULT_BASE_DIR: doing so can both miss the subject data and erase
466
+ # unrelated learning state.
184
467
  try:
185
468
  from superlocalmemory.learning.database import LearningDatabase
186
- from superlocalmemory.core.config import DEFAULT_BASE_DIR
187
- learning_db = LearningDatabase(DEFAULT_BASE_DIR / "learning.db")
469
+ if data_root is None:
470
+ raise RuntimeError("active data root could not be resolved")
471
+ learning_db = LearningDatabase(data_root / "learning.db")
188
472
  learning_db.reset(profile_id)
189
473
  counts["learning_db"] = 1
190
- except Exception:
191
- pass
474
+ except Exception as exc:
475
+ logger.warning("GDPR erase: learning-db reset failed: %s", exc)
476
+ counts["learning_db_failed"] = 1
192
477
 
193
478
  # VACUUM to remove deleted data from physical file
194
479
  try:
@@ -196,6 +481,54 @@ class GDPRCompliance:
196
481
  except Exception:
197
482
  pass
198
483
 
484
+ # Fail-closed completeness: re-count residue across the wiped tables and
485
+ # surface an explicit erasure_complete flag so a partial wipe is reported
486
+ # as failure rather than silent success.
487
+ residue_rows = 0
488
+ residue_recount_failed = False
489
+ for table in tables:
490
+ try:
491
+ _r = self._db.execute(
492
+ f"SELECT COUNT(*) AS c FROM {table} WHERE profile_id = ?",
493
+ (profile_id,),
494
+ )
495
+ residue_rows += int(dict(_r[0])["c"]) if _r else 0
496
+ except Exception as exc:
497
+ # Fail-closed: a residue re-count that cannot be performed is a
498
+ # verification failure, not zero residue. We cannot certify the
499
+ # table is clean, so erasure must not report complete.
500
+ logger.warning(
501
+ "GDPR erase: residue re-count for %s failed: %s", table, exc
502
+ )
503
+ residue_recount_failed = True
504
+ counts["residue_rows"] = residue_rows
505
+ if residue_recount_failed:
506
+ counts["residue_recount_failed"] = 1
507
+ counts["erasure_complete"] = 1 if (
508
+ residue_rows == 0
509
+ and not residue_recount_failed
510
+ and not table_delete_failures
511
+ and not counts.get("learning_db_failed")
512
+ and not counts.get("vector_store_failures")
513
+ and not counts.get("context_cache_failed")
514
+ and not counts.get("owner_erasure_incomplete")
515
+ ) else 0
516
+
517
+ try:
518
+ from superlocalmemory.compliance.audit import AuditChain
519
+ from superlocalmemory.infra.data_root import state_path
520
+ AuditChain(str(state_path("audit_chain.db"))).log(
521
+ "gdpr_erase_complete", agent_id="gdpr", profile_id=profile_id,
522
+ metadata={
523
+ "basis": "GDPR Art.17 right-to-erasure",
524
+ "tables_erased": len(tables),
525
+ "vector_store_failures": counts.get("vector_store_failures", 0),
526
+ },
527
+ )
528
+ except Exception as exc:
529
+ logger.error("GDPR erase: completion audit-chain log failed: %s", exc)
530
+ counts["audit_completion_failed"] = 1
531
+
199
532
  logger.info("GDPR erasure for '%s': %d tables, %s", profile_id, len(tables), counts)
200
533
  return counts
201
534
 
@@ -205,27 +538,88 @@ class GDPRCompliance:
205
538
  Removes facts mentioning the entity, edges, temporal events,
206
539
  and the entity itself. For targeted erasure requests.
207
540
  """
541
+ import time
542
+ requested_at = time.time()
543
+ audit_request_ok = True
544
+ try:
545
+ from superlocalmemory.compliance.audit import AuditChain
546
+ from superlocalmemory.infra.data_root import state_path
547
+ AuditChain(str(state_path("audit_chain.db"))).log(
548
+ "gdpr_erase_entity", agent_id="gdpr", profile_id=profile_id,
549
+ metadata={
550
+ "basis": "GDPR Art.17 right-to-erasure",
551
+ "entity": entity_name,
552
+ },
553
+ )
554
+ except Exception as exc:
555
+ logger.warning("GDPR entity erase: audit-chain log failed: %s", exc)
556
+ audit_request_ok = False
208
557
  self._audit("delete", "entity", entity_name,
209
558
  f"GDPR entity erasure in profile {profile_id}",
210
559
  profile_id=profile_id)
211
560
 
212
561
  entity = self._db.get_entity_by_name(entity_name, profile_id)
213
562
  if entity is None:
214
- return {"deleted": 0, "entity": entity_name, "found": False}
563
+ result: dict[str, object] = {"deleted": 0, "entity": entity_name, "found": False}
564
+ if not audit_request_ok:
565
+ result["audit_request_failed"] = 1
566
+ return result
215
567
 
216
568
  eid = entity.entity_id
217
569
  counts: dict[str, int] = {}
218
570
 
219
- # Delete facts mentioning this entity
571
+ # Delete facts mentioning this entity — use ErasureService for projection
572
+ # erasure so the receipt captures real per-owner proofs (not proofs:[]).
220
573
  rows = self._db.execute(
221
- "SELECT fact_id FROM atomic_facts WHERE profile_id = ? "
574
+ "SELECT fact_id, memory_id FROM atomic_facts WHERE profile_id = ? "
222
575
  "AND canonical_entities_json LIKE ?",
223
576
  (profile_id, f'%"{eid}"%'),
224
577
  )
225
- fact_ids = [dict(r)["fact_id"] for r in rows]
226
- for fid in fact_ids:
578
+ targets = [(dict(r)["fact_id"], dict(r).get("memory_id")) for r in rows]
579
+ target_fact_ids = [fid for fid, _ in targets]
580
+ counts["facts"] = len(targets)
581
+
582
+ if targets:
583
+ import uuid as _uuid
584
+
585
+ from superlocalmemory.core.transactions.concrete_owners import (
586
+ build_erasure_service_for_db,
587
+ )
588
+ from superlocalmemory.core.transactions.owners import OperationContext
589
+
590
+ erasure_svc = build_erasure_service_for_db(self._db, self._engine)
591
+ op_id = _uuid.uuid4().hex
592
+ ctx = OperationContext(
593
+ operation_id=op_id,
594
+ profile_id=profile_id,
595
+ subject_id=entity_name,
596
+ fact_ids=tuple(sorted(target_fact_ids)),
597
+ )
598
+ erasure_svc.remove(self._db, ctx)
599
+ receipt = erasure_svc.finalize(
600
+ self._db, ctx,
601
+ subject_type="entity",
602
+ subject_id=entity_name,
603
+ requested_by="gdpr",
604
+ requested_at=requested_at,
605
+ )
606
+ if not receipt.persisted:
607
+ counts["receipt_persist_failed"] = 1
608
+ if not receipt.all_erased:
609
+ counts["vector_store_failures"] = sum(
610
+ 1 for p in receipt.proofs if not p.erased
611
+ )
612
+
613
+ for fid, mid in targets:
227
614
  self._db.delete_fact(fid)
228
- counts["facts"] = len(fact_ids)
615
+ if mid and not self._memory_has_siblings(mid, profile_id):
616
+ try:
617
+ self._db.execute(
618
+ "DELETE FROM memories WHERE memory_id = ? AND profile_id = ?",
619
+ (mid, profile_id),
620
+ )
621
+ except Exception:
622
+ pass
229
623
 
230
624
  # Delete temporal events
231
625
  self._db.execute(
@@ -248,6 +642,8 @@ class GDPRCompliance:
248
642
  "DELETE FROM canonical_entities WHERE entity_id = ? AND profile_id = ?",
249
643
  (eid, profile_id))
250
644
  counts["entity"] = 1
645
+ if not audit_request_ok:
646
+ counts["audit_request_failed"] = 1
251
647
 
252
648
  logger.info("Entity erasure '%s' in '%s': %s", entity_name, profile_id, counts)
253
649
  return counts