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.
- package/ATTRIBUTION.md +4 -4
- package/CHANGELOG.md +113 -121
- package/README.md +65 -63
- package/docs/pi-dev-integration.md +1 -1
- package/package.json +6 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +248 -0
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +99 -0
- package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
- package/plugin-src/skills/slm-profile/SKILL.md +148 -0
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-scope/SKILL.md +176 -0
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +11 -4
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +125 -11
- package/src/superlocalmemory/cli/daemon.py +5 -1
- package/src/superlocalmemory/cli/main.py +35 -2
- package/src/superlocalmemory/cli/ops_cmd.py +281 -0
- package/src/superlocalmemory/cli/setup_wizard.py +1 -1
- package/src/superlocalmemory/compliance/audit.py +65 -0
- package/src/superlocalmemory/compliance/eu_ai_act.py +27 -57
- package/src/superlocalmemory/compliance/gdpr.py +416 -20
- package/src/superlocalmemory/compliance/retention.py +74 -22
- package/src/superlocalmemory/compliance/scheduler.py +78 -9
- package/src/superlocalmemory/core/actor_context.py +166 -0
- package/src/superlocalmemory/core/admission.py +549 -0
- package/src/superlocalmemory/core/backend_orchestrator.py +23 -10
- package/src/superlocalmemory/core/config.py +202 -24
- package/src/superlocalmemory/core/consolidation_engine.py +13 -13
- package/src/superlocalmemory/core/context_cache.py +28 -0
- package/src/superlocalmemory/core/embeddings.py +64 -2
- package/src/superlocalmemory/core/engine.py +7 -2
- package/src/superlocalmemory/core/engine_ingestion.py +65 -3
- package/src/superlocalmemory/core/engine_wiring.py +36 -9
- package/src/superlocalmemory/core/ingest_policy.py +38 -0
- package/src/superlocalmemory/core/maintenance.py +255 -0
- package/src/superlocalmemory/core/modes.py +40 -13
- package/src/superlocalmemory/core/mutations.py +437 -44
- package/src/superlocalmemory/core/operation_policy.py +92 -0
- package/src/superlocalmemory/core/operation_policy_registry.py +542 -0
- package/src/superlocalmemory/core/operation_request.py +127 -0
- package/src/superlocalmemory/core/ops_remediation.py +542 -0
- package/src/superlocalmemory/core/recall_pipeline.py +7 -0
- package/src/superlocalmemory/core/remember_runtime.py +202 -4
- package/src/superlocalmemory/core/remote_mode.py +20 -5
- package/src/superlocalmemory/core/store_pipeline.py +150 -0
- package/src/superlocalmemory/core/topic_signature.py +19 -4
- package/src/superlocalmemory/core/transactions/__init__.py +78 -0
- package/src/superlocalmemory/core/transactions/concrete_owners.py +597 -0
- package/src/superlocalmemory/core/transactions/erasure.py +825 -0
- package/src/superlocalmemory/core/transactions/manifest.py +255 -0
- package/src/superlocalmemory/core/transactions/manifest_key.py +155 -0
- package/src/superlocalmemory/core/transactions/obligations.py +272 -0
- package/src/superlocalmemory/core/transactions/owners.py +114 -0
- package/src/superlocalmemory/core/transactions/reconciler.py +285 -0
- package/src/superlocalmemory/core/transactions/service.py +330 -0
- package/src/superlocalmemory/core/worker_pool.py +33 -5
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +70 -28
- package/src/superlocalmemory/encoding/emotional.py +75 -14
- package/src/superlocalmemory/encoding/scene_builder.py +115 -13
- package/src/superlocalmemory/encoding/temporal_parser.py +4 -0
- package/src/superlocalmemory/evolution/blind_verifier.py +11 -4
- package/src/superlocalmemory/evolution/evolution_store.py +244 -4
- package/src/superlocalmemory/evolution/llm_dispatch.py +40 -0
- package/src/superlocalmemory/evolution/model_selection.py +18 -3
- package/src/superlocalmemory/evolution/mutation_generator.py +3 -0
- package/src/superlocalmemory/evolution/skill_activator.py +270 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +281 -59
- package/src/superlocalmemory/evolution/types.py +30 -8
- package/src/superlocalmemory/graph/cozo_backend.py +17 -9
- package/src/superlocalmemory/hooks/auto_invoker.py +2 -1
- package/src/superlocalmemory/hooks/auto_recall.py +64 -30
- package/src/superlocalmemory/hooks/codex_assets.py +14 -1
- package/src/superlocalmemory/infra/backup.py +434 -7
- package/src/superlocalmemory/infra/process_reaper.py +18 -0
- package/src/superlocalmemory/infra/self_heal.py +401 -0
- package/src/superlocalmemory/learning/feedback.py +52 -9
- package/src/superlocalmemory/loops/engine.py +10 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
- package/src/superlocalmemory/mcp/http_transport.py +30 -331
- package/src/superlocalmemory/mcp/profiles.py +5 -0
- package/src/superlocalmemory/mcp/resources.py +8 -0
- package/src/superlocalmemory/mcp/server.py +51 -4
- package/src/superlocalmemory/mcp/shared.py +19 -0
- package/src/superlocalmemory/mcp/tools_active.py +25 -4
- package/src/superlocalmemory/mcp/tools_code_graph.py +26 -18
- package/src/superlocalmemory/mcp/tools_context.py +50 -8
- package/src/superlocalmemory/mcp/tools_core.py +69 -21
- package/src/superlocalmemory/mcp/tools_evolution.py +9 -2
- package/src/superlocalmemory/mcp/tools_learning.py +21 -10
- package/src/superlocalmemory/mcp/tools_loops.py +29 -18
- package/src/superlocalmemory/mcp/tools_mesh.py +8 -0
- package/src/superlocalmemory/mcp/tools_ops.py +115 -0
- package/src/superlocalmemory/mcp/tools_optimize.py +4 -0
- package/src/superlocalmemory/mcp/tools_v28.py +10 -3
- package/src/superlocalmemory/mcp/tools_v3.py +34 -14
- package/src/superlocalmemory/mcp/tools_v33.py +18 -33
- package/src/superlocalmemory/mesh/broker.py +124 -46
- package/src/superlocalmemory/mesh/broker_security.py +470 -0
- package/src/superlocalmemory/mesh/discovery.py +365 -0
- package/src/superlocalmemory/mesh/lock_protocol.py +313 -0
- package/src/superlocalmemory/mesh/node_identity.py +97 -0
- package/src/superlocalmemory/mesh/outbox_remote.py +429 -0
- package/src/superlocalmemory/mesh/remote_sync.py +511 -28
- package/src/superlocalmemory/mesh/state_sync.py +286 -0
- package/src/superlocalmemory/optimize/config/store.py +45 -0
- package/src/superlocalmemory/parameterization/cross_project.py +12 -0
- package/src/superlocalmemory/parameterization/prompt_injector.py +13 -11
- package/src/superlocalmemory/parameterization/prompt_lifecycle.py +8 -2
- package/src/superlocalmemory/parameterization/workflow_miner.py +17 -0
- package/src/superlocalmemory/retrieval/ann_index.py +5 -0
- package/src/superlocalmemory/retrieval/bm25_channel.py +49 -2
- package/src/superlocalmemory/retrieval/engine.py +19 -4
- package/src/superlocalmemory/retrieval/fusion.py +4 -1
- package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -3
- package/src/superlocalmemory/retrieval/remote_reranker.py +47 -22
- package/src/superlocalmemory/retrieval/reranker.py +32 -1
- package/src/superlocalmemory/retrieval/temporal_channel.py +16 -3
- package/src/superlocalmemory/retrieval/temporal_utils.py +107 -0
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +155 -42
- package/src/superlocalmemory/retrieval/vector_store.py +214 -8
- package/src/superlocalmemory/server/api.py +5 -5
- package/src/superlocalmemory/server/egress_policy.py +258 -0
- package/src/superlocalmemory/server/rbac_enforce.py +32 -0
- package/src/superlocalmemory/server/route_mutations.py +20 -0
- package/src/superlocalmemory/server/routes/compliance.py +153 -7
- package/src/superlocalmemory/server/routes/data_io.py +43 -2
- package/src/superlocalmemory/server/routes/events.py +15 -0
- package/src/superlocalmemory/server/routes/memories.py +56 -3
- package/src/superlocalmemory/server/routes/mesh.py +82 -1
- package/src/superlocalmemory/server/routes/mesh_lock.py +54 -0
- package/src/superlocalmemory/server/routes/mesh_state.py +63 -0
- package/src/superlocalmemory/server/routes/v3_api.py +50 -27
- package/src/superlocalmemory/server/routes/ws.py +86 -0
- package/src/superlocalmemory/server/ui.py +6 -6
- package/src/superlocalmemory/server/unified_daemon.py +942 -119
- package/src/superlocalmemory/storage/_migration_internals.py +568 -0
- package/src/superlocalmemory/storage/_schema_version.py +110 -0
- package/src/superlocalmemory/storage/database.py +329 -24
- package/src/superlocalmemory/storage/embedding_migrator.py +246 -51
- package/src/superlocalmemory/storage/erasure_fence.py +45 -0
- package/src/superlocalmemory/storage/generation_fence.py +63 -0
- package/src/superlocalmemory/storage/migration_runner.py +140 -417
- package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +40 -0
- package/src/superlocalmemory/storage/migrations/M033_projection_transactions.py +148 -0
- package/src/superlocalmemory/storage/migrations/M034_obligation_integrity.py +58 -0
- package/src/superlocalmemory/storage/migrations/M035_erasure_receipts.py +113 -0
- package/src/superlocalmemory/storage/migrations/M036_vector_row_map.py +107 -0
- package/src/superlocalmemory/storage/migrations/M037_manifest_hmac_version.py +162 -0
- package/src/superlocalmemory/storage/migrations/{M033_learning_feedback_channel.py → M038_learning_feedback_channel.py} +3 -3
- package/src/superlocalmemory/storage/migrations/M039_scene_fact_members.py +137 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +4 -2
- package/src/superlocalmemory/storage/schema.py +67 -0
- package/src/superlocalmemory/storage/write_coordinator.py +125 -0
- package/src/superlocalmemory/trust/scorer.py +28 -4
- package/src/superlocalmemory/ui/index.html +14 -3
- package/src/superlocalmemory/ui/js/auto-settings.js +12 -1
- package/src/superlocalmemory/ui/js/brain.js +6 -4
- package/src/superlocalmemory/ui/js/compliance.js +66 -12
- package/src/superlocalmemory/ui/js/dashboard.js +13 -3
- package/src/superlocalmemory/ui/js/feedback.js +8 -2
- package/src/superlocalmemory/ui/js/lifecycle.js +7 -1
- package/src/superlocalmemory/ui/js/modal.js +272 -5
- package/src/superlocalmemory/ui/js/od-backup.js +9 -2
- package/src/superlocalmemory/ui/js/od-compliance-ext.js +301 -0
- package/src/superlocalmemory/ui/js/od-operations.js +154 -23
- package/src/superlocalmemory/ui/js/od-ops-health.js +417 -0
- package/src/superlocalmemory/ui/js/od-optimize.js +35 -21
- package/src/superlocalmemory/ui/js/od-team.js +9 -2
- package/src/superlocalmemory/ui/js/optimize.js +13 -16
- package/src/superlocalmemory/ui/js/profiles.js +7 -3
- package/src/superlocalmemory/ui/js/settings.js +7 -1
- package/src/superlocalmemory/vector/lancedb_backend.py +19 -9
- package/src/superlocalmemory/attribution/mathematical_dna.py +0 -235
- package/src/superlocalmemory/cli/post_install.py +0 -114
- package/src/superlocalmemory/core/clock_monitor.py +0 -45
- package/src/superlocalmemory/core/db_pool.py +0 -80
- package/src/superlocalmemory/core/error_catalog.py +0 -113
- package/src/superlocalmemory/core/loop_watchdog.py +0 -56
- package/src/superlocalmemory/core/priority_queue.py +0 -61
- package/src/superlocalmemory/core/pruning_engine.py +0 -216
- package/src/superlocalmemory/core/queue_dispatcher.py +0 -73
- package/src/superlocalmemory/core/slmignore.py +0 -125
- package/src/superlocalmemory/infra/heartbeat_monitor.py +0 -140
- package/src/superlocalmemory/infra/webhook_dispatcher.py +0 -247
- package/src/superlocalmemory/learning/quantization_scheduler.py +0 -320
- 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.
|
|
44
|
-
# separately, deleted last).
|
|
45
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
for fid in
|
|
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
|
-
|
|
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
|