superlocalmemory 4.0.9 → 4.1.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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +245 -0
- package/README.md +7 -7
- package/package.json +4 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +4 -4
- 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 +2 -2
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +5 -5
- package/plugin/skills/slm-recall/SKILL.md +102 -15
- package/plugin/skills/slm-remember/SKILL.md +35 -3
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +29 -3
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +16 -8
- 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 +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +2 -2
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/SKILL.md +5 -5
- package/plugin-src/skills/slm-recall/SKILL.md +102 -15
- package/plugin-src/skills/slm-remember/SKILL.md +35 -3
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +29 -3
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +308 -20
- package/src/superlocalmemory/cli/daemon.py +30 -0
- package/src/superlocalmemory/cli/db_migrate.py +71 -1
- package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
- package/src/superlocalmemory/cli/main.py +26 -4
- package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
- package/src/superlocalmemory/code_graph/database.py +44 -0
- package/src/superlocalmemory/compliance/gdpr.py +449 -39
- package/src/superlocalmemory/core/admission.py +231 -11
- package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
- package/src/superlocalmemory/core/config.py +90 -11
- package/src/superlocalmemory/core/consolidation_engine.py +34 -0
- package/src/superlocalmemory/core/engine.py +140 -11
- package/src/superlocalmemory/core/fact_consolidator.py +316 -125
- package/src/superlocalmemory/core/graph_analyzer.py +76 -112
- package/src/superlocalmemory/core/graph_metrics.py +597 -0
- package/src/superlocalmemory/core/graph_pruner.py +121 -0
- package/src/superlocalmemory/core/maintenance.py +44 -6
- package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
- package/src/superlocalmemory/core/memory_health.py +266 -0
- package/src/superlocalmemory/core/mode_capability.py +111 -0
- package/src/superlocalmemory/core/ollama_validator.py +315 -0
- package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
- package/src/superlocalmemory/core/operation_request.py +1 -1
- package/src/superlocalmemory/core/ops_remediation.py +2 -2
- package/src/superlocalmemory/core/projection_drain.py +380 -0
- package/src/superlocalmemory/core/recall_pipeline.py +390 -3
- package/src/superlocalmemory/core/recall_worker.py +6 -3
- package/src/superlocalmemory/core/scale_autopromote.py +196 -0
- package/src/superlocalmemory/core/scale_engine.py +16 -2
- package/src/superlocalmemory/core/score_contract.py +21 -1
- package/src/superlocalmemory/core/session_identity.py +85 -0
- package/src/superlocalmemory/core/status_contract.py +108 -0
- package/src/superlocalmemory/core/store_pipeline.py +78 -3
- package/src/superlocalmemory/core/worker_pool.py +4 -4
- package/src/superlocalmemory/core/working_memory.py +288 -0
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +51 -7
- package/src/superlocalmemory/encoding/context_generator.py +1 -1
- package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
- package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
- package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
- package/src/superlocalmemory/encoding/type_router.py +12 -12
- package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
- package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
- package/src/superlocalmemory/graph/cozo_backend.py +103 -138
- package/src/superlocalmemory/hooks/portable_kit.py +10 -2
- package/src/superlocalmemory/learning/bandit.py +43 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
- package/src/superlocalmemory/learning/database.py +60 -3
- package/src/superlocalmemory/learning/entity_compiler.py +21 -58
- package/src/superlocalmemory/learning/feedback.py +3 -1
- package/src/superlocalmemory/learning/outcomes.py +47 -16
- package/src/superlocalmemory/learning/pattern_miner.py +28 -3
- package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
- package/src/superlocalmemory/learning/pcos.py +291 -0
- package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
- package/src/superlocalmemory/learning/reward_proxy.py +100 -10
- package/src/superlocalmemory/learning/signal_kinds.py +79 -0
- package/src/superlocalmemory/mcp/profiles.py +14 -2
- package/src/superlocalmemory/mcp/server.py +1 -1
- package/src/superlocalmemory/mcp/session_binding.py +92 -0
- package/src/superlocalmemory/mcp/tools_active.py +2 -1
- package/src/superlocalmemory/mcp/tools_core.py +71 -42
- package/src/superlocalmemory/mcp/tools_ops.py +2 -2
- package/src/superlocalmemory/mcp/tools_v28.py +20 -1
- package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
- package/src/superlocalmemory/retrieval/bm25_channel.py +68 -11
- package/src/superlocalmemory/retrieval/channel_status.py +117 -0
- package/src/superlocalmemory/retrieval/engine.py +106 -11
- package/src/superlocalmemory/retrieval/entity_channel.py +217 -257
- package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
- package/src/superlocalmemory/retrieval/scope_policy.py +42 -1
- package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
- package/src/superlocalmemory/retrieval/spreading.py +288 -0
- package/src/superlocalmemory/retrieval/temporal_channel.py +13 -1
- package/src/superlocalmemory/retrieval/vector_store.py +63 -0
- package/src/superlocalmemory/server/api.py +26 -2
- package/src/superlocalmemory/server/asset_versions.py +171 -0
- package/src/superlocalmemory/server/bandit_loops.py +17 -1
- package/src/superlocalmemory/server/rbac_enforce.py +26 -6
- package/src/superlocalmemory/server/recall_serializer.py +9 -0
- package/src/superlocalmemory/server/routes/abstraction.py +201 -0
- package/src/superlocalmemory/server/routes/behavioral.py +75 -10
- package/src/superlocalmemory/server/routes/compliance.py +98 -18
- package/src/superlocalmemory/server/routes/config_api.py +186 -4
- package/src/superlocalmemory/server/routes/data_io.py +29 -1
- package/src/superlocalmemory/server/routes/entity.py +13 -1
- package/src/superlocalmemory/server/routes/evolution.py +178 -0
- package/src/superlocalmemory/server/routes/ingest.py +8 -0
- package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
- package/src/superlocalmemory/server/routes/memories.py +49 -7
- package/src/superlocalmemory/server/routes/mesh.py +1 -1
- package/src/superlocalmemory/server/routes/timeline.py +4 -0
- package/src/superlocalmemory/server/routes/v3_api.py +193 -17
- package/src/superlocalmemory/server/ui.py +24 -1
- package/src/superlocalmemory/server/unified_daemon.py +292 -9
- package/src/superlocalmemory/storage/_migration_internals.py +35 -0
- package/src/superlocalmemory/storage/_schema_version.py +24 -3
- package/src/superlocalmemory/storage/database.py +598 -82
- package/src/superlocalmemory/storage/embedding_codec.py +71 -0
- package/src/superlocalmemory/storage/lineage_retention.py +236 -0
- package/src/superlocalmemory/storage/logical_edges.py +43 -2
- package/src/superlocalmemory/storage/migration_runner.py +130 -0
- package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
- package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
- package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
- package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
- package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
- package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
- package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
- package/src/superlocalmemory/storage/migrations.py +18 -2
- package/src/superlocalmemory/storage/models.py +40 -1
- package/src/superlocalmemory/storage/projection_outbox.py +346 -0
- package/src/superlocalmemory/storage/retention_policy.py +860 -0
- package/src/superlocalmemory/storage/schema.py +110 -1
- package/src/superlocalmemory/storage/write_coordinator.py +19 -2
- package/src/superlocalmemory/summaries/base.py +1 -1
- package/src/superlocalmemory/summaries/non_answer.py +223 -0
- package/src/superlocalmemory/trust/scorer.py +43 -1
- package/src/superlocalmemory/ui/index.html +10 -19
- package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
- package/src/superlocalmemory/ui/js/od-health.js +28 -6
- package/src/superlocalmemory/ui/js/od-memories.js +209 -1
- package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
- package/src/superlocalmemory/ui/js/od-settings.js +87 -1
- package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
|
@@ -13,6 +13,7 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
|
13
13
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
|
+
import json
|
|
16
17
|
import logging
|
|
17
18
|
import sqlite3
|
|
18
19
|
from datetime import UTC, datetime
|
|
@@ -83,6 +84,39 @@ _EXPORT_ALIASES = {
|
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
|
|
87
|
+
def _unproject_entity(entity_id: str) -> bool:
|
|
88
|
+
"""Remove an entity node from the graph projection, if there is one.
|
|
89
|
+
|
|
90
|
+
The projection is a separate storage engine, so deleting the row from
|
|
91
|
+
SQLite reaches nothing there. An entity node left behind still carries the
|
|
92
|
+
name it was created with, which is exactly the thing an erasure was asked
|
|
93
|
+
to remove.
|
|
94
|
+
|
|
95
|
+
Returns False when a projection exists and refused, so a caller that
|
|
96
|
+
reports completeness can report this honestly. No projection at all is not
|
|
97
|
+
a failure -- there is nothing to remove.
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
from superlocalmemory.core.backend_orchestrator import get_orchestrator
|
|
101
|
+
except Exception: # noqa: BLE001
|
|
102
|
+
return True
|
|
103
|
+
try:
|
|
104
|
+
orchestrator = get_orchestrator()
|
|
105
|
+
graph = orchestrator.get_graph_backend() if orchestrator else None
|
|
106
|
+
if graph is None:
|
|
107
|
+
return True
|
|
108
|
+
remove = getattr(graph, "remove_entity", None)
|
|
109
|
+
if remove is None:
|
|
110
|
+
return True
|
|
111
|
+
remove(entity_id)
|
|
112
|
+
return True
|
|
113
|
+
except Exception as exc: # noqa: BLE001
|
|
114
|
+
logger.warning(
|
|
115
|
+
"the graph projection still holds entity %s (%s)", entity_id[:12], exc,
|
|
116
|
+
)
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
|
|
86
120
|
class GDPRCompliance:
|
|
87
121
|
"""GDPR compliance operations for memory data.
|
|
88
122
|
|
|
@@ -134,26 +168,63 @@ class GDPRCompliance:
|
|
|
134
168
|
except Exception:
|
|
135
169
|
pass
|
|
136
170
|
|
|
137
|
-
def
|
|
171
|
+
def _purge_graph_and_vector_projections(
|
|
172
|
+
self, profile_id: str,
|
|
173
|
+
) -> tuple[int, int]:
|
|
174
|
+
"""Remove a tenant's facts from CozoDB and LanceDB. Returns (purged, failed).
|
|
175
|
+
|
|
176
|
+
These projections live in their own storage engines — RocksDB under Cozo,
|
|
177
|
+
Lance files under the vector store — so nothing about deleting rows from
|
|
178
|
+
SQLite reaches them. A memory left in the graph after erasure is still
|
|
179
|
+
reachable by recall, which makes the erasure receipt a false statement.
|
|
180
|
+
|
|
181
|
+
A store with no projection open returns ``(0, 0)``: there is nothing to
|
|
182
|
+
purge, which is not a failure. A projection that is open and refuses to
|
|
183
|
+
delete IS a failure and is counted, so ``erasure_complete`` cannot come
|
|
184
|
+
back true while a tenant's facts are still in the graph.
|
|
185
|
+
"""
|
|
138
186
|
try:
|
|
139
|
-
|
|
187
|
+
from superlocalmemory.core.backend_orchestrator import get_orchestrator
|
|
140
188
|
except Exception:
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if
|
|
144
|
-
return
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
189
|
+
return 0, 0
|
|
190
|
+
orchestrator = get_orchestrator()
|
|
191
|
+
if orchestrator is None:
|
|
192
|
+
return 0, 0
|
|
193
|
+
graph = orchestrator.get_graph_backend()
|
|
194
|
+
vector = orchestrator.get_vector_backend()
|
|
195
|
+
if graph is None and vector is None:
|
|
196
|
+
return 0, 0
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
fact_ids = [
|
|
200
|
+
dict(r)["fact_id"]
|
|
201
|
+
for r in self._db.execute(
|
|
202
|
+
"SELECT fact_id FROM atomic_facts WHERE profile_id = ?",
|
|
203
|
+
(profile_id,),
|
|
204
|
+
)
|
|
205
|
+
]
|
|
206
|
+
except Exception as exc:
|
|
207
|
+
logger.warning("GDPR erase: could not list facts to unproject: %s", exc)
|
|
208
|
+
return 0, 1
|
|
209
|
+
|
|
210
|
+
purged = failures = 0
|
|
211
|
+
for fact_id in fact_ids:
|
|
212
|
+
removed = True
|
|
213
|
+
for backend, method in ((graph, "remove_fact"), (vector, "remove_vector")):
|
|
214
|
+
if backend is None:
|
|
215
|
+
continue
|
|
216
|
+
try:
|
|
217
|
+
getattr(backend, method)(fact_id)
|
|
218
|
+
except Exception as exc:
|
|
219
|
+
logger.warning(
|
|
220
|
+
"GDPR erase: %s failed for %s: %s", method, fact_id[:12], exc,
|
|
221
|
+
)
|
|
222
|
+
removed = False
|
|
223
|
+
if removed:
|
|
224
|
+
purged += 1
|
|
225
|
+
else:
|
|
226
|
+
failures += 1
|
|
227
|
+
return purged, failures
|
|
157
228
|
|
|
158
229
|
def _purge_vector_and_ann(self, profile_id: str) -> tuple[int, int]:
|
|
159
230
|
engine = self._engine
|
|
@@ -299,6 +370,16 @@ class GDPRCompliance:
|
|
|
299
370
|
if code_graph_data is not None:
|
|
300
371
|
data["code_graph"] = code_graph_data
|
|
301
372
|
|
|
373
|
+
# What the system learned about how this person works: which
|
|
374
|
+
# questions they asked, which answers they found useful, and the
|
|
375
|
+
# weights derived from that. It was erased on request and could not
|
|
376
|
+
# be asked for -- which makes an export that claims to be everything
|
|
377
|
+
# untrue. It lives in a second file, so the table sweep above never
|
|
378
|
+
# reached it.
|
|
379
|
+
behaviour = self._export_learning_signals(self._data_root, profile_id)
|
|
380
|
+
if behaviour is not None:
|
|
381
|
+
data["learning_signals"] = behaviour
|
|
382
|
+
|
|
302
383
|
# total_items counts the canonical (table-name) keys only, before
|
|
303
384
|
# friendly aliases are added, so it is not double-counted.
|
|
304
385
|
data["total_items"] = sum(len(v) for v in data.values() if isinstance(v, list))
|
|
@@ -436,6 +517,26 @@ class GDPRCompliance:
|
|
|
436
517
|
logger.warning("GDPR erase: vector purge failed: %s", exc)
|
|
437
518
|
counts["vector_store_failures"] = counts.get("vector_store_failures", 0) or 1
|
|
438
519
|
|
|
520
|
+
# The graph and vector projections are separate storage engines, so a
|
|
521
|
+
# profile wipe against SQLite does not reach them. They have to be
|
|
522
|
+
# purged here, before Pass 2: that sweep finds a tenant's tables by
|
|
523
|
+
# looking for a profile_id column, which includes projection_outbox — so
|
|
524
|
+
# it would delete the very rows that carry the queued removals, leaving
|
|
525
|
+
# the erased facts in the graph with nothing left to take them out.
|
|
526
|
+
try:
|
|
527
|
+
graph_purged, graph_failures = self._purge_graph_and_vector_projections(
|
|
528
|
+
profile_id
|
|
529
|
+
)
|
|
530
|
+
if graph_purged:
|
|
531
|
+
counts["graph_projection"] = graph_purged
|
|
532
|
+
if graph_failures:
|
|
533
|
+
counts["graph_projection_failures"] = graph_failures
|
|
534
|
+
except Exception as exc:
|
|
535
|
+
logger.warning("GDPR erase: graph projection purge failed: %s", exc)
|
|
536
|
+
counts["graph_projection_failures"] = (
|
|
537
|
+
counts.get("graph_projection_failures", 0) or 1
|
|
538
|
+
)
|
|
539
|
+
|
|
439
540
|
# Erasure receipt (P1-5) — route the profile wipe through ErasureService
|
|
440
541
|
# so the receipt captures real per-owner proofs (not proofs:[]).
|
|
441
542
|
#
|
|
@@ -498,13 +599,23 @@ class GDPRCompliance:
|
|
|
498
599
|
counts["receipt_error"] = str(exc)
|
|
499
600
|
raise
|
|
500
601
|
|
|
501
|
-
# C2 — erase code_graph.db before main
|
|
502
|
-
#
|
|
503
|
-
#
|
|
504
|
-
#
|
|
602
|
+
# C2 — erase this profile's share of code_graph.db before the main
|
|
603
|
+
# profile rows. How much of it is "this profile's share" depends on
|
|
604
|
+
# whether anybody else is in the store; see _erase_code_graph. A failure
|
|
605
|
+
# here does not abort the rest of the erasure, but it does block the
|
|
606
|
+
# completeness claim below.
|
|
505
607
|
if data_root is not None:
|
|
506
|
-
code_graph_result = self._erase_code_graph(
|
|
608
|
+
code_graph_result = self._erase_code_graph(
|
|
609
|
+
data_root,
|
|
610
|
+
profile_id=profile_id,
|
|
611
|
+
sole_profile=self._is_sole_profile(profile_id),
|
|
612
|
+
)
|
|
507
613
|
counts["code_graph"] = code_graph_result.get("rows_deleted", 0)
|
|
614
|
+
counts["code_graph_scope"] = code_graph_result.get("scope", "")
|
|
615
|
+
if code_graph_result.get("retained_reason"):
|
|
616
|
+
counts["code_graph_retained_reason"] = code_graph_result[
|
|
617
|
+
"retained_reason"
|
|
618
|
+
]
|
|
508
619
|
if code_graph_result.get("error"):
|
|
509
620
|
counts["code_graph_failed"] = 1
|
|
510
621
|
|
|
@@ -535,6 +646,12 @@ class GDPRCompliance:
|
|
|
535
646
|
"learning receipt purge failed; profile deletion was not started"
|
|
536
647
|
) from exc
|
|
537
648
|
|
|
649
|
+
# Tables keyed on a fact rather than on a profile are invisible to the
|
|
650
|
+
# profile-scoped sweep below, which discovers tables by looking for a
|
|
651
|
+
# profile_id column. They have to go FIRST, while the facts that name
|
|
652
|
+
# them still exist to be joined against.
|
|
653
|
+
self._erase_fact_keyed_tables(profile_id, counts)
|
|
654
|
+
|
|
538
655
|
# Pass 2 — full-tenant wipe with FK enforcement OFF so table order is
|
|
539
656
|
# irrelevant (every profile row in every table goes). FTS shadow rows
|
|
540
657
|
# are still removed by the base-table delete triggers.
|
|
@@ -627,6 +744,25 @@ class GDPRCompliance:
|
|
|
627
744
|
"Backup snapshots may still contain the erased profile's data."
|
|
628
745
|
)
|
|
629
746
|
|
|
747
|
+
# In-process residue. The erased profile's recently-shown memories are
|
|
748
|
+
# held in a per-session working set that biases ranking; those are the
|
|
749
|
+
# subject's data too, and a later session reusing one of their session
|
|
750
|
+
# ids would otherwise inherit the bias.
|
|
751
|
+
#
|
|
752
|
+
# Runs after every DB delete — an in-memory dict cannot participate in a
|
|
753
|
+
# rollback, so clearing it earlier would be unrecoverable if the wipe
|
|
754
|
+
# then failed — but BEFORE the completeness verdict below, and counted by
|
|
755
|
+
# it. Computing the verdict first would let this fail while the API
|
|
756
|
+
# reported a complete erasure, which is the one thing an Art.17 receipt
|
|
757
|
+
# must never do.
|
|
758
|
+
try:
|
|
759
|
+
from superlocalmemory.core.working_memory import discard_profile
|
|
760
|
+
|
|
761
|
+
counts["working_sets"] = discard_profile(profile_id)
|
|
762
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
763
|
+
logger.warning("GDPR erase: working-set discard failed: %s", exc)
|
|
764
|
+
counts["working_sets_failed"] = 1
|
|
765
|
+
|
|
630
766
|
counts["erasure_complete"] = (
|
|
631
767
|
1
|
|
632
768
|
if (
|
|
@@ -636,11 +772,37 @@ class GDPRCompliance:
|
|
|
636
772
|
and not counts.get("learning_db_failed")
|
|
637
773
|
and not counts.get("learning_db_skipped")
|
|
638
774
|
and not counts.get("vector_store_failures")
|
|
775
|
+
# A fact still in the graph is still recallable, so an erasure
|
|
776
|
+
# that could not reach the projection is not complete.
|
|
777
|
+
and not counts.get("graph_projection_failures")
|
|
639
778
|
and not counts.get("context_cache_failed")
|
|
640
779
|
and not counts.get("owner_erasure_incomplete")
|
|
641
780
|
and not counts.get("backup_obligations_pending")
|
|
642
781
|
and not counts.get("backup_scan_failed")
|
|
643
782
|
and not counts.get("fts_residue_rows")
|
|
783
|
+
and not counts.get("working_sets_failed")
|
|
784
|
+
and not counts.get("code_graph_failed")
|
|
785
|
+
and not any(
|
|
786
|
+
counts.get(f"{t}_failed") for t, _ in self._FACT_KEYED_TABLES
|
|
787
|
+
)
|
|
788
|
+
)
|
|
789
|
+
else 0
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
# Whether the data is gone and whether we can PROVE it is gone are two
|
|
793
|
+
# different questions, and one answer cannot carry both. Article 5(2) is
|
|
794
|
+
# accountability: an erasure whose tamper-evident receipt was not
|
|
795
|
+
# written really did delete the rows, and really cannot be demonstrated
|
|
796
|
+
# afterwards. Folding that into erasure_complete would report an
|
|
797
|
+
# erasure that happened as one that did not; leaving it out entirely —
|
|
798
|
+
# which is what happened until now — lets a caller reading one field
|
|
799
|
+
# believe it covers both.
|
|
800
|
+
counts["erasure_provable"] = (
|
|
801
|
+
1
|
|
802
|
+
if (
|
|
803
|
+
counts["erasure_complete"] == 1
|
|
804
|
+
and not counts.get("receipt_persist_failed")
|
|
805
|
+
and not counts.get("receipt_error")
|
|
644
806
|
)
|
|
645
807
|
else 0
|
|
646
808
|
)
|
|
@@ -752,6 +914,14 @@ class GDPRCompliance:
|
|
|
752
914
|
if not receipt.all_erased:
|
|
753
915
|
counts["vector_store_failures"] = sum(1 for p in receipt.proofs if not p.erased)
|
|
754
916
|
|
|
917
|
+
# The same residue the profile wipe had: the search-expansion index is
|
|
918
|
+
# keyed on a fact, not a profile, and ``delete_fact`` does not touch it.
|
|
919
|
+
# Erasing an entity left the alternate keys of its memories behind, and
|
|
920
|
+
# a search could still match them.
|
|
921
|
+
self._erase_fact_keyed_tables_for(
|
|
922
|
+
[fid for fid, _mid in targets], counts,
|
|
923
|
+
)
|
|
924
|
+
|
|
755
925
|
for fid, mid in targets:
|
|
756
926
|
self._db.delete_fact(fid)
|
|
757
927
|
if mid and not self._memory_has_siblings(mid, profile_id):
|
|
@@ -784,23 +954,178 @@ class GDPRCompliance:
|
|
|
784
954
|
"DELETE FROM canonical_entities WHERE entity_id = ? AND profile_id = ?",
|
|
785
955
|
(eid, profile_id),
|
|
786
956
|
)
|
|
957
|
+
# The graph holds its own copy of this node, with the name in it.
|
|
958
|
+
# Deleting the row above does not reach it.
|
|
959
|
+
if not _unproject_entity(eid):
|
|
960
|
+
counts["projection_failed"] = 1
|
|
787
961
|
counts["entity"] = 1
|
|
788
962
|
if not audit_request_ok:
|
|
789
963
|
counts["audit_request_failed"] = 1
|
|
790
964
|
|
|
965
|
+
# The same two questions the profile path answers. Their absence here
|
|
966
|
+
# meant a caller could not tell a complete entity erasure from a partial
|
|
967
|
+
# one at all — it just got a dict of counts.
|
|
968
|
+
counts["erasure_complete"] = (
|
|
969
|
+
0
|
|
970
|
+
if any(
|
|
971
|
+
counts.get(marker)
|
|
972
|
+
for marker in (
|
|
973
|
+
"vector_store_failures",
|
|
974
|
+
"audit_request_failed",
|
|
975
|
+
*(f"{table}_failed" for table, _ in self._FACT_KEYED_TABLES),
|
|
976
|
+
)
|
|
977
|
+
)
|
|
978
|
+
else 1
|
|
979
|
+
)
|
|
980
|
+
counts["erasure_provable"] = (
|
|
981
|
+
1
|
|
982
|
+
if counts["erasure_complete"] == 1
|
|
983
|
+
and not counts.get("receipt_persist_failed")
|
|
984
|
+
and not counts.get("audit_completion_failed")
|
|
985
|
+
else 0
|
|
986
|
+
)
|
|
987
|
+
|
|
791
988
|
logger.info("Entity erasure '%s' in '%s': %s", entity_name, profile_id, counts)
|
|
792
989
|
return counts
|
|
793
990
|
|
|
794
991
|
# -- C2: code_graph helpers --------------------------------------------
|
|
795
992
|
|
|
796
|
-
|
|
797
|
-
|
|
993
|
+
#: Tables that hold a person's text but are keyed on a fact, not a profile.
|
|
994
|
+
#: The erasure sweep finds tables by looking for a profile_id column, so
|
|
995
|
+
#: these are invisible to it — the search-expansion index kept a copy of a
|
|
996
|
+
#: memory's alternate keys after every trace of the memory itself was gone.
|
|
997
|
+
#: Erasing them needs a join, and the join needs the facts to still exist,
|
|
998
|
+
#: so it runs before the sweep rather than after.
|
|
999
|
+
_FACT_KEYED_TABLES: tuple[tuple[str, str], ...] = (
|
|
1000
|
+
("fact_expansion_fts", "fact_id"),
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
def _erase_fact_keyed_tables_for(
|
|
1004
|
+
self, fact_ids: list[str], counts: dict,
|
|
1005
|
+
) -> None:
|
|
1006
|
+
"""Erase fact-keyed rows for an explicit list of facts.
|
|
1007
|
+
|
|
1008
|
+
The profile wipe derives the list from a profile; a targeted entity
|
|
1009
|
+
erasure already knows which facts it is removing. Both need the same
|
|
1010
|
+
tables cleared, so they share the loop rather than one of them
|
|
1011
|
+
forgetting — which is exactly what happened.
|
|
1012
|
+
"""
|
|
1013
|
+
if not fact_ids:
|
|
1014
|
+
return
|
|
1015
|
+
for table, column in self._FACT_KEYED_TABLES:
|
|
1016
|
+
try:
|
|
1017
|
+
exists = self._db.execute(
|
|
1018
|
+
"SELECT 1 FROM sqlite_master WHERE name = ?", (table,)
|
|
1019
|
+
)
|
|
1020
|
+
except Exception as exc: # noqa: BLE001
|
|
1021
|
+
logger.warning("GDPR erase: cannot look for %s: %s", table, exc)
|
|
1022
|
+
counts[f"{table}_failed"] = 1
|
|
1023
|
+
continue
|
|
1024
|
+
if not exists:
|
|
1025
|
+
continue
|
|
1026
|
+
removed = 0
|
|
1027
|
+
try:
|
|
1028
|
+
for start in range(0, len(fact_ids), 500):
|
|
1029
|
+
chunk = fact_ids[start:start + 500]
|
|
1030
|
+
placeholders = ",".join("?" * len(chunk))
|
|
1031
|
+
present = self._db.execute(
|
|
1032
|
+
f"SELECT COUNT(*) AS c FROM {table} "
|
|
1033
|
+
f"WHERE {column} IN ({placeholders})",
|
|
1034
|
+
tuple(chunk),
|
|
1035
|
+
)
|
|
1036
|
+
removed += int(dict(present[0])["c"]) if present else 0
|
|
1037
|
+
self._db.execute(
|
|
1038
|
+
f"DELETE FROM {table} WHERE {column} IN ({placeholders})",
|
|
1039
|
+
tuple(chunk),
|
|
1040
|
+
)
|
|
1041
|
+
except Exception as exc: # noqa: BLE001
|
|
1042
|
+
logger.warning("GDPR erase: delete from %s failed: %s", table, exc)
|
|
1043
|
+
counts[f"{table}_failed"] = 1
|
|
1044
|
+
continue
|
|
1045
|
+
counts[table] = counts.get(table, 0) + removed
|
|
1046
|
+
|
|
1047
|
+
def _erase_fact_keyed_tables(self, profile_id: str, counts: dict) -> None:
|
|
1048
|
+
"""Delete rows that name this profile's facts but not the profile.
|
|
798
1049
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
1050
|
+
Delegates rather than repeating the loop. It WAS a second copy, and the
|
|
1051
|
+
commit that introduced the shared helper claimed otherwise — which is
|
|
1052
|
+
exactly the failure the helper existed to prevent: the entity path could
|
|
1053
|
+
have been fixed with the profile path left untouched, and nothing would
|
|
1054
|
+
have noticed.
|
|
1055
|
+
"""
|
|
1056
|
+
fact_ids = self._fact_ids_for(profile_id)
|
|
1057
|
+
if fact_ids is None:
|
|
1058
|
+
# Could not find out what to erase. Say so; do not report zero.
|
|
1059
|
+
for table, _column in self._FACT_KEYED_TABLES:
|
|
1060
|
+
counts[f"{table}_failed"] = 1
|
|
1061
|
+
return
|
|
1062
|
+
self._erase_fact_keyed_tables_for(fact_ids, counts)
|
|
1063
|
+
|
|
1064
|
+
def _is_sole_profile(self, profile_id: str) -> bool:
|
|
1065
|
+
"""Whether this profile is the only one in the store.
|
|
1066
|
+
|
|
1067
|
+
Decides how much of the shared code graph an erasure may take. Errs
|
|
1068
|
+
toward FALSE — the narrower erasure — because failing to answer is not
|
|
1069
|
+
a reason to delete somebody else's records.
|
|
1070
|
+
"""
|
|
1071
|
+
try:
|
|
1072
|
+
rows = self._db.execute("SELECT profile_id FROM profiles")
|
|
1073
|
+
except Exception as exc: # noqa: BLE001 - reported by erring narrow
|
|
1074
|
+
logger.warning(
|
|
1075
|
+
"GDPR erase: could not count profiles (%s); erasing only this "
|
|
1076
|
+
"profile's own rows from the code graph", exc,
|
|
1077
|
+
)
|
|
1078
|
+
return False
|
|
1079
|
+
found = set()
|
|
1080
|
+
for row in rows:
|
|
1081
|
+
try:
|
|
1082
|
+
found.add(str(dict(row)["profile_id"]))
|
|
1083
|
+
except Exception: # noqa: BLE001 - row shape varies by driver
|
|
1084
|
+
found.add(str(row[0]))
|
|
1085
|
+
return found <= {profile_id}
|
|
1086
|
+
|
|
1087
|
+
def _fact_ids_for(self, profile_id: str) -> list[str] | None:
|
|
1088
|
+
"""Every fact id belonging to this profile, for cross-database joins.
|
|
1089
|
+
|
|
1090
|
+
Returns None when the listing FAILED, which is not the same as a
|
|
1091
|
+
profile with no facts. Collapsing the two is how a locked database
|
|
1092
|
+
turned into "nothing to erase" and then into a receipt saying complete.
|
|
1093
|
+
"""
|
|
1094
|
+
try:
|
|
1095
|
+
rows = self._db.execute(
|
|
1096
|
+
"SELECT fact_id FROM atomic_facts WHERE profile_id = ?",
|
|
1097
|
+
(profile_id,),
|
|
1098
|
+
)
|
|
1099
|
+
except Exception as exc: # noqa: BLE001
|
|
1100
|
+
logger.warning("GDPR erase: could not list facts for %r: %s", profile_id, exc)
|
|
1101
|
+
return None
|
|
1102
|
+
out: list[str] = []
|
|
1103
|
+
for row in rows:
|
|
1104
|
+
try:
|
|
1105
|
+
out.append(str(dict(row)["fact_id"]))
|
|
1106
|
+
except Exception: # noqa: BLE001
|
|
1107
|
+
out.append(str(row[0]))
|
|
1108
|
+
return out
|
|
1109
|
+
|
|
1110
|
+
def _erase_code_graph(
|
|
1111
|
+
self, data_root: Path, *, profile_id: str = "", sole_profile: bool = True,
|
|
1112
|
+
) -> dict:
|
|
1113
|
+
"""Erase this profile's share of the live code_graph.db (C2 — Art.17).
|
|
1114
|
+
|
|
1115
|
+
The graph carries repository paths, file names and symbol names, and no
|
|
1116
|
+
table in it has a profile_id. When the store holds ONE profile the whole
|
|
1117
|
+
graph belongs to that person and wiping it is exactly right.
|
|
1118
|
+
|
|
1119
|
+
When it holds more than one, wiping it destroys the other people's data
|
|
1120
|
+
too — and an erasure request that erases a second data subject is itself
|
|
1121
|
+
a breach, not an over-achievement. So in that case only the rows that
|
|
1122
|
+
are unambiguously this profile's are removed: ``code_memory_links``
|
|
1123
|
+
joins a code node to an SLM fact, and facts carry a profile. The graph
|
|
1124
|
+
of the source code stays, because it describes a repository rather than
|
|
1125
|
+
a person, and the receipt says plainly that it was left.
|
|
1126
|
+
|
|
1127
|
+
Fail-open: an error is recorded in the returned dict so the caller can
|
|
1128
|
+
surface it, but it does NOT abort the rest of the erasure.
|
|
804
1129
|
"""
|
|
805
1130
|
result: dict = {"rows_deleted": 0}
|
|
806
1131
|
code_graph_path = data_root / "code_graph.db"
|
|
@@ -820,15 +1145,39 @@ class GDPRCompliance:
|
|
|
820
1145
|
]
|
|
821
1146
|
total = 0
|
|
822
1147
|
conn.execute("BEGIN")
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1148
|
+
if sole_profile:
|
|
1149
|
+
for tbl in tables:
|
|
1150
|
+
# Skip FTS virtual-table shadow files — deleting base rows handles them
|
|
1151
|
+
if tbl.endswith((
|
|
1152
|
+
"_fts", "_fts_data", "_fts_idx",
|
|
1153
|
+
"_fts_content", "_fts_docsize", "_fts_config",
|
|
1154
|
+
)):
|
|
1155
|
+
continue
|
|
1156
|
+
cur = conn.execute(f"DELETE FROM {tbl}") # noqa: S608
|
|
1157
|
+
total += cur.rowcount
|
|
1158
|
+
result["scope"] = "whole_graph"
|
|
1159
|
+
else:
|
|
1160
|
+
result["scope"] = "links_only"
|
|
1161
|
+
result["retained_reason"] = (
|
|
1162
|
+
"another profile shares this graph; the source-code "
|
|
1163
|
+
"structure describes a repository, not this person, and "
|
|
1164
|
+
"deleting it would erase another data subject's records"
|
|
1165
|
+
)
|
|
1166
|
+
owned = self._fact_ids_for(profile_id) if profile_id else []
|
|
1167
|
+
if owned is None:
|
|
1168
|
+
# Not knowing which links are this person's is a failure
|
|
1169
|
+
# to erase, not an empty erasure.
|
|
1170
|
+
raise sqlite3.OperationalError(
|
|
1171
|
+
"could not list this profile's facts, so its code "
|
|
1172
|
+
"links cannot be identified"
|
|
1173
|
+
)
|
|
1174
|
+
if "code_memory_links" in tables and owned:
|
|
1175
|
+
cur = conn.execute(
|
|
1176
|
+
"DELETE FROM code_memory_links WHERE slm_fact_id IN "
|
|
1177
|
+
"(SELECT value FROM json_each(?))",
|
|
1178
|
+
(json.dumps(owned),),
|
|
1179
|
+
)
|
|
1180
|
+
total += cur.rowcount
|
|
832
1181
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
833
1182
|
conn.execute("COMMIT")
|
|
834
1183
|
# VACUUM must run outside any transaction (autocommit mode required)
|
|
@@ -883,6 +1232,67 @@ class GDPRCompliance:
|
|
|
883
1232
|
return None
|
|
884
1233
|
return export if export else None
|
|
885
1234
|
|
|
1235
|
+
def _export_learning_signals(
|
|
1236
|
+
self, data_root: Path, profile_id: str
|
|
1237
|
+
) -> dict | None:
|
|
1238
|
+
"""Read this workspace's rows out of ``learning.db`` for an export.
|
|
1239
|
+
|
|
1240
|
+
Everything the system inferred about how somebody works is personal
|
|
1241
|
+
data about them, and an export that leaves it out is not the export it
|
|
1242
|
+
says it is. Erasure already covers this file; asking for it did not.
|
|
1243
|
+
|
|
1244
|
+
Scoped by ``profile_id`` wherever the table has one, and skipped where
|
|
1245
|
+
it does not -- a table with no workspace column holds nothing that can
|
|
1246
|
+
be attributed to one workspace, and returning it would export another
|
|
1247
|
+
workspace's rows into this person's file.
|
|
1248
|
+
"""
|
|
1249
|
+
learning_path = data_root / "learning.db"
|
|
1250
|
+
if not learning_path.exists():
|
|
1251
|
+
return None
|
|
1252
|
+
export: dict = {}
|
|
1253
|
+
try:
|
|
1254
|
+
conn = sqlite3.connect(
|
|
1255
|
+
f"file:{learning_path}?mode=ro", uri=True, timeout=5,
|
|
1256
|
+
)
|
|
1257
|
+
conn.row_factory = sqlite3.Row
|
|
1258
|
+
try:
|
|
1259
|
+
tables = [
|
|
1260
|
+
row[0] for row in conn.execute(
|
|
1261
|
+
"SELECT name FROM sqlite_master WHERE type='table' "
|
|
1262
|
+
"AND name NOT LIKE 'sqlite_%'"
|
|
1263
|
+
).fetchall()
|
|
1264
|
+
]
|
|
1265
|
+
for table in tables:
|
|
1266
|
+
if table.endswith((
|
|
1267
|
+
"_fts", "_fts_data", "_fts_idx",
|
|
1268
|
+
"_fts_content", "_fts_docsize", "_fts_config",
|
|
1269
|
+
)):
|
|
1270
|
+
continue
|
|
1271
|
+
try:
|
|
1272
|
+
columns = {
|
|
1273
|
+
row[1] for row in
|
|
1274
|
+
conn.execute(f"PRAGMA table_info({table})") # noqa: S608
|
|
1275
|
+
}
|
|
1276
|
+
if "profile_id" not in columns:
|
|
1277
|
+
continue
|
|
1278
|
+
rows = conn.execute(
|
|
1279
|
+
f"SELECT * FROM {table} WHERE profile_id = ? " # noqa: S608
|
|
1280
|
+
f"LIMIT 10000",
|
|
1281
|
+
(profile_id,),
|
|
1282
|
+
).fetchall()
|
|
1283
|
+
if rows:
|
|
1284
|
+
export[table] = [dict(row) for row in rows]
|
|
1285
|
+
except Exception as exc: # noqa: BLE001
|
|
1286
|
+
logger.warning(
|
|
1287
|
+
"export: learning table %s skipped: %s", table, exc,
|
|
1288
|
+
)
|
|
1289
|
+
finally:
|
|
1290
|
+
conn.close()
|
|
1291
|
+
except Exception as exc: # noqa: BLE001
|
|
1292
|
+
logger.warning("export: learning.db could not be read: %s", exc)
|
|
1293
|
+
return None
|
|
1294
|
+
return export or None
|
|
1295
|
+
|
|
886
1296
|
# -- C1: backup obligation helpers -------------------------------------
|
|
887
1297
|
|
|
888
1298
|
def _record_backup_obligations(
|