superlocalmemory 4.0.10 → 4.1.2

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 (145) hide show
  1. package/.claude-plugin/marketplace.json +12 -2
  2. package/CHANGELOG.md +244 -0
  3. package/README.md +40 -75
  4. package/package.json +6 -3
  5. package/plugin/.claude-plugin/plugin.json +2 -2
  6. package/plugin/CLAUDE.md +3 -3
  7. package/plugin/agents/slm-governance-advisor.md +1 -1
  8. package/plugin/agents/slm-loop-runner.md +4 -4
  9. package/plugin/agents/slm-memory-advisor.md +1 -1
  10. package/plugin/agents/slm-optimize-advisor.md +1 -1
  11. package/plugin/requirements.txt +1 -1
  12. package/plugin/skills/slm-cache/SKILL.md +1 -1
  13. package/plugin/skills/slm-compress/SKILL.md +1 -1
  14. package/plugin/skills/slm-governance/SKILL.md +1 -1
  15. package/plugin/skills/slm-graph/SKILL.md +1 -1
  16. package/plugin/skills/slm-loop/SKILL.md +2 -2
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +5 -5
  19. package/plugin/skills/slm-recall/SKILL.md +102 -15
  20. package/plugin/skills/slm-remember/SKILL.md +35 -3
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +29 -3
  23. package/plugin/skills/slm-status/SKILL.md +1 -1
  24. package/plugin-src/rules/AGENTS.md +16 -8
  25. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-loop/SKILL.md +2 -2
  30. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-profile/SKILL.md +5 -5
  32. package/plugin-src/skills/slm-recall/SKILL.md +102 -15
  33. package/plugin-src/skills/slm-remember/SKILL.md +35 -3
  34. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-session/SKILL.md +29 -3
  36. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  37. package/pyproject.toml +1 -1
  38. package/src/superlocalmemory/__init__.py +1 -1
  39. package/src/superlocalmemory/cli/commands.py +357 -18
  40. package/src/superlocalmemory/cli/daemon.py +30 -0
  41. package/src/superlocalmemory/cli/db_migrate.py +71 -1
  42. package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
  43. package/src/superlocalmemory/cli/main.py +24 -2
  44. package/src/superlocalmemory/code_graph/database.py +44 -0
  45. package/src/superlocalmemory/compliance/gdpr.py +449 -39
  46. package/src/superlocalmemory/core/admission.py +231 -11
  47. package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
  48. package/src/superlocalmemory/core/config.py +90 -11
  49. package/src/superlocalmemory/core/consolidation_engine.py +34 -0
  50. package/src/superlocalmemory/core/engine.py +140 -11
  51. package/src/superlocalmemory/core/graph_analyzer.py +76 -112
  52. package/src/superlocalmemory/core/graph_metrics.py +597 -0
  53. package/src/superlocalmemory/core/graph_pruner.py +121 -0
  54. package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
  55. package/src/superlocalmemory/core/mode_capability.py +111 -0
  56. package/src/superlocalmemory/core/ollama_validator.py +315 -0
  57. package/src/superlocalmemory/core/projection_drain.py +380 -0
  58. package/src/superlocalmemory/core/recall_pipeline.py +390 -3
  59. package/src/superlocalmemory/core/recall_worker.py +6 -3
  60. package/src/superlocalmemory/core/scale_autopromote.py +196 -0
  61. package/src/superlocalmemory/core/scale_engine.py +16 -2
  62. package/src/superlocalmemory/core/score_contract.py +21 -1
  63. package/src/superlocalmemory/core/session_identity.py +85 -0
  64. package/src/superlocalmemory/core/status_contract.py +108 -0
  65. package/src/superlocalmemory/core/worker_pool.py +4 -4
  66. package/src/superlocalmemory/core/working_memory.py +288 -0
  67. package/src/superlocalmemory/encoding/cognitive_consolidator.py +36 -6
  68. package/src/superlocalmemory/encoding/context_generator.py +1 -1
  69. package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
  70. package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
  71. package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
  72. package/src/superlocalmemory/encoding/type_router.py +12 -12
  73. package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
  74. package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
  75. package/src/superlocalmemory/graph/cozo_backend.py +103 -138
  76. package/src/superlocalmemory/hooks/portable_kit.py +10 -2
  77. package/src/superlocalmemory/learning/bandit.py +43 -0
  78. package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
  79. package/src/superlocalmemory/learning/database.py +60 -3
  80. package/src/superlocalmemory/learning/entity_compiler.py +21 -58
  81. package/src/superlocalmemory/learning/feedback.py +3 -1
  82. package/src/superlocalmemory/learning/outcomes.py +47 -16
  83. package/src/superlocalmemory/learning/pattern_miner.py +28 -3
  84. package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
  85. package/src/superlocalmemory/learning/pcos.py +291 -0
  86. package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
  87. package/src/superlocalmemory/learning/reward_proxy.py +100 -10
  88. package/src/superlocalmemory/learning/signal_kinds.py +79 -0
  89. package/src/superlocalmemory/mcp/profiles.py +14 -2
  90. package/src/superlocalmemory/mcp/tools_active.py +2 -1
  91. package/src/superlocalmemory/mcp/tools_core.py +31 -3
  92. package/src/superlocalmemory/mcp/tools_v28.py +20 -1
  93. package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
  94. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
  95. package/src/superlocalmemory/retrieval/bm25_channel.py +64 -3
  96. package/src/superlocalmemory/retrieval/channel_status.py +117 -0
  97. package/src/superlocalmemory/retrieval/engine.py +106 -11
  98. package/src/superlocalmemory/retrieval/entity_channel.py +210 -256
  99. package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
  100. package/src/superlocalmemory/retrieval/scope_policy.py +20 -0
  101. package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
  102. package/src/superlocalmemory/retrieval/spreading.py +288 -0
  103. package/src/superlocalmemory/server/api.py +24 -5
  104. package/src/superlocalmemory/server/bandit_loops.py +17 -1
  105. package/src/superlocalmemory/server/rbac_enforce.py +26 -6
  106. package/src/superlocalmemory/server/recall_health.py +87 -10
  107. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  108. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  109. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  110. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  111. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  112. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  113. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  114. package/src/superlocalmemory/server/routes/memories.py +49 -7
  115. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  116. package/src/superlocalmemory/server/routes/v3_api.py +191 -15
  117. package/src/superlocalmemory/server/ui.py +20 -4
  118. package/src/superlocalmemory/server/unified_daemon.py +241 -7
  119. package/src/superlocalmemory/storage/_migration_internals.py +54 -2
  120. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  121. package/src/superlocalmemory/storage/database.py +477 -59
  122. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  123. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  124. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  125. package/src/superlocalmemory/storage/migration_runner.py +119 -0
  126. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +60 -36
  127. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  128. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  129. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  130. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  131. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  132. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  133. package/src/superlocalmemory/storage/migrations.py +18 -2
  134. package/src/superlocalmemory/storage/models.py +40 -1
  135. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  136. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  137. package/src/superlocalmemory/storage/schema.py +35 -1
  138. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  139. package/src/superlocalmemory/trust/scorer.py +43 -1
  140. package/src/superlocalmemory/ui/index.html +9 -18
  141. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  142. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  143. package/src/superlocalmemory/ui/js/od-memories.js +19 -0
  144. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  145. 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 _purge_fact_projections(self, fact_id: str, profile_id: str) -> None:
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
- self._db.delete_bm25_tokens_for_fact(fact_id)
187
+ from superlocalmemory.core.backend_orchestrator import get_orchestrator
140
188
  except Exception:
141
- pass
142
- engine = self._engine
143
- if engine is None:
144
- return
145
- store = getattr(engine, "_vector_store", None)
146
- ann = getattr(engine, "_ann_index", None)
147
- if store is not None and getattr(store, "available", False):
148
- try:
149
- store.delete(fact_id)
150
- except Exception:
151
- pass
152
- if ann is not None and hasattr(ann, "remove"):
153
- try:
154
- ann.remove(fact_id)
155
- except Exception:
156
- pass
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 profile rows (fail-closed: a
502
- # code_graph failure is logged but does NOT abort the erasure; the
503
- # graph is installation-level personal data with no profile_id column,
504
- # so it is wiped entirely on any Art.17 request).
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(data_root)
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
- def _erase_code_graph(self, data_root: Path) -> dict:
797
- """Wipe all rows from the live code_graph.db (C2 Art.17 scope).
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
- code_graph.db carries repo paths, file names and symbol names —
800
- identifying data in a work context with no profile_id column. The
801
- entire graph is wiped on any Art.17 erasure request. Fail-open: an
802
- error is recorded in the returned dict so the caller can surface it,
803
- but it does NOT abort the rest of the erasure.
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
- for tbl in tables:
824
- # Skip FTS virtual-table shadow files — deleting base rows handles them
825
- if tbl.endswith((
826
- "_fts", "_fts_data", "_fts_idx",
827
- "_fts_content", "_fts_docsize", "_fts_config",
828
- )):
829
- continue
830
- cur = conn.execute(f"DELETE FROM {tbl}") # noqa: S608
831
- total += cur.rowcount
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(