superlocalmemory 3.8.13 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/ATTRIBUTION.md +4 -4
  2. package/CHANGELOG.md +113 -121
  3. package/README.md +65 -63
  4. package/docs/pi-dev-integration.md +1 -1
  5. package/package.json +6 -1
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/CLAUDE.md +3 -3
  8. package/plugin/agents/slm-governance-advisor.md +1 -1
  9. package/plugin/agents/slm-loop-runner.md +1 -1
  10. package/plugin/agents/slm-memory-advisor.md +1 -1
  11. package/plugin/agents/slm-optimize-advisor.md +1 -1
  12. package/plugin/requirements.txt +1 -1
  13. package/plugin/skills/slm-cache/SKILL.md +1 -1
  14. package/plugin/skills/slm-compress/SKILL.md +1 -1
  15. package/plugin/skills/slm-governance/SKILL.md +1 -1
  16. package/plugin/skills/slm-graph/SKILL.md +1 -1
  17. package/plugin/skills/slm-loop/SKILL.md +1 -1
  18. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  19. package/plugin/skills/slm-profile/SKILL.md +1 -1
  20. package/plugin/skills/slm-recall/SKILL.md +1 -1
  21. package/plugin/skills/slm-remember/SKILL.md +1 -1
  22. package/plugin/skills/slm-scope/SKILL.md +1 -1
  23. package/plugin/skills/slm-session/SKILL.md +1 -1
  24. package/plugin/skills/slm-status/SKILL.md +1 -1
  25. package/plugin-src/rules/AGENTS.md +1 -1
  26. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-governance/SKILL.md +248 -0
  29. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-loop/SKILL.md +99 -0
  31. package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
  32. package/plugin-src/skills/slm-profile/SKILL.md +148 -0
  33. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-scope/SKILL.md +176 -0
  36. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  37. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  38. package/pyproject.toml +11 -4
  39. package/src/superlocalmemory/__init__.py +1 -1
  40. package/src/superlocalmemory/cli/commands.py +125 -11
  41. package/src/superlocalmemory/cli/daemon.py +5 -1
  42. package/src/superlocalmemory/cli/main.py +35 -2
  43. package/src/superlocalmemory/cli/ops_cmd.py +281 -0
  44. package/src/superlocalmemory/cli/setup_wizard.py +1 -1
  45. package/src/superlocalmemory/compliance/audit.py +65 -0
  46. package/src/superlocalmemory/compliance/eu_ai_act.py +27 -57
  47. package/src/superlocalmemory/compliance/gdpr.py +416 -20
  48. package/src/superlocalmemory/compliance/retention.py +74 -22
  49. package/src/superlocalmemory/compliance/scheduler.py +78 -9
  50. package/src/superlocalmemory/core/actor_context.py +166 -0
  51. package/src/superlocalmemory/core/admission.py +549 -0
  52. package/src/superlocalmemory/core/backend_orchestrator.py +23 -10
  53. package/src/superlocalmemory/core/config.py +202 -24
  54. package/src/superlocalmemory/core/consolidation_engine.py +13 -13
  55. package/src/superlocalmemory/core/context_cache.py +28 -0
  56. package/src/superlocalmemory/core/embeddings.py +64 -2
  57. package/src/superlocalmemory/core/engine.py +7 -2
  58. package/src/superlocalmemory/core/engine_ingestion.py +65 -3
  59. package/src/superlocalmemory/core/engine_wiring.py +36 -9
  60. package/src/superlocalmemory/core/ingest_policy.py +38 -0
  61. package/src/superlocalmemory/core/maintenance.py +255 -0
  62. package/src/superlocalmemory/core/modes.py +40 -13
  63. package/src/superlocalmemory/core/mutations.py +437 -44
  64. package/src/superlocalmemory/core/operation_policy.py +92 -0
  65. package/src/superlocalmemory/core/operation_policy_registry.py +542 -0
  66. package/src/superlocalmemory/core/operation_request.py +127 -0
  67. package/src/superlocalmemory/core/ops_remediation.py +542 -0
  68. package/src/superlocalmemory/core/recall_pipeline.py +7 -0
  69. package/src/superlocalmemory/core/remember_runtime.py +202 -4
  70. package/src/superlocalmemory/core/remote_mode.py +20 -5
  71. package/src/superlocalmemory/core/store_pipeline.py +150 -0
  72. package/src/superlocalmemory/core/topic_signature.py +19 -4
  73. package/src/superlocalmemory/core/transactions/__init__.py +78 -0
  74. package/src/superlocalmemory/core/transactions/concrete_owners.py +597 -0
  75. package/src/superlocalmemory/core/transactions/erasure.py +825 -0
  76. package/src/superlocalmemory/core/transactions/manifest.py +255 -0
  77. package/src/superlocalmemory/core/transactions/manifest_key.py +155 -0
  78. package/src/superlocalmemory/core/transactions/obligations.py +272 -0
  79. package/src/superlocalmemory/core/transactions/owners.py +114 -0
  80. package/src/superlocalmemory/core/transactions/reconciler.py +285 -0
  81. package/src/superlocalmemory/core/transactions/service.py +330 -0
  82. package/src/superlocalmemory/core/worker_pool.py +33 -5
  83. package/src/superlocalmemory/encoding/cognitive_consolidator.py +70 -28
  84. package/src/superlocalmemory/encoding/emotional.py +75 -14
  85. package/src/superlocalmemory/encoding/scene_builder.py +115 -13
  86. package/src/superlocalmemory/encoding/temporal_parser.py +4 -0
  87. package/src/superlocalmemory/evolution/blind_verifier.py +11 -4
  88. package/src/superlocalmemory/evolution/evolution_store.py +244 -4
  89. package/src/superlocalmemory/evolution/llm_dispatch.py +40 -0
  90. package/src/superlocalmemory/evolution/model_selection.py +18 -3
  91. package/src/superlocalmemory/evolution/mutation_generator.py +3 -0
  92. package/src/superlocalmemory/evolution/skill_activator.py +270 -0
  93. package/src/superlocalmemory/evolution/skill_evolver.py +281 -59
  94. package/src/superlocalmemory/evolution/types.py +30 -8
  95. package/src/superlocalmemory/graph/cozo_backend.py +17 -9
  96. package/src/superlocalmemory/hooks/auto_invoker.py +2 -1
  97. package/src/superlocalmemory/hooks/auto_recall.py +64 -30
  98. package/src/superlocalmemory/hooks/codex_assets.py +14 -1
  99. package/src/superlocalmemory/infra/backup.py +434 -7
  100. package/src/superlocalmemory/infra/process_reaper.py +18 -0
  101. package/src/superlocalmemory/infra/self_heal.py +401 -0
  102. package/src/superlocalmemory/learning/feedback.py +52 -9
  103. package/src/superlocalmemory/loops/engine.py +10 -0
  104. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  105. package/src/superlocalmemory/mcp/http_transport.py +30 -331
  106. package/src/superlocalmemory/mcp/profiles.py +5 -0
  107. package/src/superlocalmemory/mcp/resources.py +8 -0
  108. package/src/superlocalmemory/mcp/server.py +51 -4
  109. package/src/superlocalmemory/mcp/shared.py +19 -0
  110. package/src/superlocalmemory/mcp/tools_active.py +25 -4
  111. package/src/superlocalmemory/mcp/tools_code_graph.py +26 -18
  112. package/src/superlocalmemory/mcp/tools_context.py +50 -8
  113. package/src/superlocalmemory/mcp/tools_core.py +69 -21
  114. package/src/superlocalmemory/mcp/tools_evolution.py +9 -2
  115. package/src/superlocalmemory/mcp/tools_learning.py +21 -10
  116. package/src/superlocalmemory/mcp/tools_loops.py +29 -18
  117. package/src/superlocalmemory/mcp/tools_mesh.py +8 -0
  118. package/src/superlocalmemory/mcp/tools_ops.py +115 -0
  119. package/src/superlocalmemory/mcp/tools_optimize.py +4 -0
  120. package/src/superlocalmemory/mcp/tools_v28.py +10 -3
  121. package/src/superlocalmemory/mcp/tools_v3.py +34 -14
  122. package/src/superlocalmemory/mcp/tools_v33.py +18 -33
  123. package/src/superlocalmemory/mesh/broker.py +124 -46
  124. package/src/superlocalmemory/mesh/broker_security.py +470 -0
  125. package/src/superlocalmemory/mesh/discovery.py +365 -0
  126. package/src/superlocalmemory/mesh/lock_protocol.py +313 -0
  127. package/src/superlocalmemory/mesh/node_identity.py +97 -0
  128. package/src/superlocalmemory/mesh/outbox_remote.py +429 -0
  129. package/src/superlocalmemory/mesh/remote_sync.py +511 -28
  130. package/src/superlocalmemory/mesh/state_sync.py +286 -0
  131. package/src/superlocalmemory/optimize/config/store.py +45 -0
  132. package/src/superlocalmemory/parameterization/cross_project.py +12 -0
  133. package/src/superlocalmemory/parameterization/prompt_injector.py +13 -11
  134. package/src/superlocalmemory/parameterization/prompt_lifecycle.py +8 -2
  135. package/src/superlocalmemory/parameterization/workflow_miner.py +17 -0
  136. package/src/superlocalmemory/retrieval/ann_index.py +5 -0
  137. package/src/superlocalmemory/retrieval/bm25_channel.py +49 -2
  138. package/src/superlocalmemory/retrieval/engine.py +19 -4
  139. package/src/superlocalmemory/retrieval/fusion.py +4 -1
  140. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -3
  141. package/src/superlocalmemory/retrieval/remote_reranker.py +47 -22
  142. package/src/superlocalmemory/retrieval/reranker.py +32 -1
  143. package/src/superlocalmemory/retrieval/temporal_channel.py +16 -3
  144. package/src/superlocalmemory/retrieval/temporal_utils.py +107 -0
  145. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +155 -42
  146. package/src/superlocalmemory/retrieval/vector_store.py +214 -8
  147. package/src/superlocalmemory/server/api.py +5 -5
  148. package/src/superlocalmemory/server/egress_policy.py +258 -0
  149. package/src/superlocalmemory/server/rbac_enforce.py +32 -0
  150. package/src/superlocalmemory/server/route_mutations.py +20 -0
  151. package/src/superlocalmemory/server/routes/compliance.py +153 -7
  152. package/src/superlocalmemory/server/routes/data_io.py +43 -2
  153. package/src/superlocalmemory/server/routes/events.py +15 -0
  154. package/src/superlocalmemory/server/routes/memories.py +56 -3
  155. package/src/superlocalmemory/server/routes/mesh.py +82 -1
  156. package/src/superlocalmemory/server/routes/mesh_lock.py +54 -0
  157. package/src/superlocalmemory/server/routes/mesh_state.py +63 -0
  158. package/src/superlocalmemory/server/routes/v3_api.py +50 -27
  159. package/src/superlocalmemory/server/routes/ws.py +86 -0
  160. package/src/superlocalmemory/server/ui.py +6 -6
  161. package/src/superlocalmemory/server/unified_daemon.py +942 -119
  162. package/src/superlocalmemory/storage/_migration_internals.py +568 -0
  163. package/src/superlocalmemory/storage/_schema_version.py +110 -0
  164. package/src/superlocalmemory/storage/database.py +329 -24
  165. package/src/superlocalmemory/storage/embedding_migrator.py +246 -51
  166. package/src/superlocalmemory/storage/erasure_fence.py +45 -0
  167. package/src/superlocalmemory/storage/generation_fence.py +63 -0
  168. package/src/superlocalmemory/storage/migration_runner.py +140 -417
  169. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +40 -0
  170. package/src/superlocalmemory/storage/migrations/M033_projection_transactions.py +148 -0
  171. package/src/superlocalmemory/storage/migrations/M034_obligation_integrity.py +58 -0
  172. package/src/superlocalmemory/storage/migrations/M035_erasure_receipts.py +113 -0
  173. package/src/superlocalmemory/storage/migrations/M036_vector_row_map.py +107 -0
  174. package/src/superlocalmemory/storage/migrations/M037_manifest_hmac_version.py +162 -0
  175. package/src/superlocalmemory/storage/migrations/{M033_learning_feedback_channel.py → M038_learning_feedback_channel.py} +3 -3
  176. package/src/superlocalmemory/storage/migrations/M039_scene_fact_members.py +137 -0
  177. package/src/superlocalmemory/storage/migrations/__init__.py +4 -2
  178. package/src/superlocalmemory/storage/schema.py +67 -0
  179. package/src/superlocalmemory/storage/write_coordinator.py +125 -0
  180. package/src/superlocalmemory/trust/scorer.py +28 -4
  181. package/src/superlocalmemory/ui/index.html +14 -3
  182. package/src/superlocalmemory/ui/js/auto-settings.js +12 -1
  183. package/src/superlocalmemory/ui/js/brain.js +6 -4
  184. package/src/superlocalmemory/ui/js/compliance.js +66 -12
  185. package/src/superlocalmemory/ui/js/dashboard.js +13 -3
  186. package/src/superlocalmemory/ui/js/feedback.js +8 -2
  187. package/src/superlocalmemory/ui/js/lifecycle.js +7 -1
  188. package/src/superlocalmemory/ui/js/modal.js +272 -5
  189. package/src/superlocalmemory/ui/js/od-backup.js +9 -2
  190. package/src/superlocalmemory/ui/js/od-compliance-ext.js +301 -0
  191. package/src/superlocalmemory/ui/js/od-operations.js +154 -23
  192. package/src/superlocalmemory/ui/js/od-ops-health.js +417 -0
  193. package/src/superlocalmemory/ui/js/od-optimize.js +35 -21
  194. package/src/superlocalmemory/ui/js/od-team.js +9 -2
  195. package/src/superlocalmemory/ui/js/optimize.js +13 -16
  196. package/src/superlocalmemory/ui/js/profiles.js +7 -3
  197. package/src/superlocalmemory/ui/js/settings.js +7 -1
  198. package/src/superlocalmemory/vector/lancedb_backend.py +19 -9
  199. package/src/superlocalmemory/attribution/mathematical_dna.py +0 -235
  200. package/src/superlocalmemory/cli/post_install.py +0 -114
  201. package/src/superlocalmemory/core/clock_monitor.py +0 -45
  202. package/src/superlocalmemory/core/db_pool.py +0 -80
  203. package/src/superlocalmemory/core/error_catalog.py +0 -113
  204. package/src/superlocalmemory/core/loop_watchdog.py +0 -56
  205. package/src/superlocalmemory/core/priority_queue.py +0 -61
  206. package/src/superlocalmemory/core/pruning_engine.py +0 -216
  207. package/src/superlocalmemory/core/queue_dispatcher.py +0 -73
  208. package/src/superlocalmemory/core/slmignore.py +0 -125
  209. package/src/superlocalmemory/infra/heartbeat_monitor.py +0 -140
  210. package/src/superlocalmemory/infra/webhook_dispatcher.py +0 -247
  211. package/src/superlocalmemory/learning/quantization_scheduler.py +0 -320
  212. package/src/superlocalmemory/storage/access_control.py +0 -182
@@ -6,6 +6,8 @@
6
6
  from __future__ import annotations
7
7
 
8
8
  import logging
9
+ import sqlite3
10
+ from pathlib import Path
9
11
  from typing import Any
10
12
 
11
13
  logger = logging.getLogger("superlocalmemory.audit")
@@ -36,6 +38,308 @@ def _context(
36
38
  return profile_id, context
37
39
 
38
40
 
41
+ def _invalidate_context_cache_for_fact(
42
+ engine: Any,
43
+ fact_id: str,
44
+ ) -> None:
45
+ """Best-effort removal of context cache entries referencing fact_id.
46
+
47
+ Opens the cache SQLite file directly (no ContextCache instance needed).
48
+ Tries the two standard cache locations relative to the database directory:
49
+ a ``context-cache`` subdirectory (isolation convention used in tests) and
50
+ the database directory itself (production default). Fail-open: any error
51
+ is silently discarded.
52
+ """
53
+ try:
54
+ db_parent = Path(engine._db.db_path).parent
55
+ except AttributeError:
56
+ return
57
+ pattern = f'%"{fact_id}"%'
58
+ candidates = [
59
+ db_parent / "context-cache" / "active_brain_cache.db",
60
+ db_parent / "active_brain_cache.db",
61
+ ]
62
+ for cache_path in candidates:
63
+ if not cache_path.exists():
64
+ continue
65
+ try:
66
+ conn = sqlite3.connect(str(cache_path), timeout=2.0)
67
+ try:
68
+ conn.execute(
69
+ "DELETE FROM context_entries WHERE fact_ids LIKE ?",
70
+ (pattern,),
71
+ )
72
+ conn.commit()
73
+ finally:
74
+ conn.close()
75
+ except Exception:
76
+ pass
77
+
78
+
79
+ def _sync_vector_ann(
80
+ retrieval: Any,
81
+ fact_id: str,
82
+ profile_id: str,
83
+ embedding: list[float] | None,
84
+ *,
85
+ operation: str,
86
+ ) -> None:
87
+ """Replace or remove a fact's entry in the vector store and ANN index.
88
+
89
+ ``operation`` is either ``"update"`` or ``"delete"``. For updates,
90
+ ``embedding`` must be provided; the semantic representations are replaced
91
+ in place so a correction is reflected by every semantic query. Fail-open:
92
+ errors are logged and ignored so a projection hiccup never blocks the
93
+ authoritative write.
94
+ """
95
+ vector_store = getattr(retrieval, "_vector_store", None)
96
+ ann_index = getattr(retrieval, "_ann_index", None)
97
+ if operation == "update" and embedding:
98
+ if vector_store is not None:
99
+ try:
100
+ # upsert replaces the stored vector for an existing fact_id.
101
+ vector_store.upsert(fact_id, profile_id, embedding)
102
+ except Exception as exc:
103
+ logger.warning("vector_store upsert failed for %s: %s", fact_id[:16], exc)
104
+ if ann_index is not None:
105
+ try:
106
+ # add() is upsert-semantic: it overwrites an existing entry.
107
+ ann_index.add(fact_id, embedding)
108
+ except Exception as exc:
109
+ logger.warning("ann_index add failed for %s: %s", fact_id[:16], exc)
110
+ elif operation == "delete":
111
+ if vector_store is not None:
112
+ try:
113
+ vector_store.delete(fact_id)
114
+ except Exception as exc:
115
+ logger.warning("vector_store.delete failed for %s: %s", fact_id[:16], exc)
116
+ if ann_index is not None:
117
+ try:
118
+ ann_index.remove(fact_id)
119
+ except Exception as exc:
120
+ logger.warning("ann_index.remove failed for %s: %s", fact_id[:16], exc)
121
+
122
+
123
+ def _converge_update_projections(
124
+ engine: Any,
125
+ fact_id: str,
126
+ content: str,
127
+ profile_id: str,
128
+ embedding: list[float] | None,
129
+ ) -> None:
130
+ """Fan out a fact correction to every derived projection.
131
+
132
+ Projection obligations on correction:
133
+ - temporal_events.description — updated to new content
134
+ - fact_context.contextual_description — cleared of stale tokens (row kept)
135
+ - fact_expansion_fts.alt_keys — cleared of stale tokens (row kept)
136
+ - context cache — entry deleted so stale content is never served
137
+ - BM25 live channel — replaced (one entry, no duplicate)
138
+ - vector store + ANN index — update called
139
+ """
140
+ db = engine._db
141
+
142
+ # Temporal events: description must match new content
143
+ db.update_temporal_event_description(fact_id, content)
144
+
145
+ # Fact context: keep row alive but clear stale description/keywords
146
+ db.store_fact_context(fact_id, profile_id, "", "")
147
+
148
+ # Expansion FTS: keep row alive but replace with empty alt_keys
149
+ db.reset_fact_expansion(fact_id, "")
150
+
151
+ # Context cache: delete stale entries so the hot path returns nothing stale
152
+ _invalidate_context_cache_for_fact(engine, fact_id)
153
+
154
+ # BM25 live channel: replace (remove then add) to guarantee exactly one entry
155
+ retrieval = getattr(engine, "_retrieval_engine", None)
156
+ bm25 = getattr(retrieval, "_bm25", None) if retrieval else None
157
+ if bm25 is not None and hasattr(bm25, "update_fact"):
158
+ bm25.update_fact(fact_id, content, profile_id)
159
+ elif bm25 is not None:
160
+ # Fallback: add without dedup (legacy path)
161
+ bm25.add(fact_id, content, profile_id)
162
+
163
+ # Vector store + ANN index
164
+ if retrieval is not None:
165
+ _sync_vector_ann(retrieval, fact_id, profile_id, embedding, operation="update")
166
+
167
+ try:
168
+ from superlocalmemory.core.backend_orchestrator import get_orchestrator
169
+ orchestrator = get_orchestrator()
170
+ if orchestrator is not None:
171
+ orchestrator.sync_changed_fact(fact_id)
172
+ except Exception:
173
+ logger.warning("Derived projection update sync failed for %s", fact_id[:16])
174
+
175
+
176
+ def _purge_delete_projections(
177
+ engine: Any,
178
+ fact_id: str,
179
+ profile_id: str,
180
+ *,
181
+ memory_id: str | None = None,
182
+ ) -> None:
183
+ """Purge every derived representation of a fact before the canonical row is gone.
184
+
185
+ Projection obligations on deletion:
186
+ - memories (raw memory record that sourced this fact)
187
+ - bm25_tokens (persisted tokens in the DB table)
188
+ - fact_expansion_fts (BM25 alt-key expansion entry)
189
+ - graph_edges (any edge where fact_id is source or target)
190
+ - memory_scenes (fact_id removed from JSON arrays; empty scenes deleted)
191
+ - fact_context (contextual description entry)
192
+ - context cache (entries referencing this fact_id)
193
+ - BM25 live channel (evicted from in-memory corpus)
194
+ - vector store + ANN index (delete / remove called)
195
+ """
196
+ db = engine._db
197
+
198
+ if memory_id:
199
+ try:
200
+ siblings = db.execute(
201
+ "SELECT 1 FROM atomic_facts "
202
+ "WHERE memory_id = ? AND profile_id = ? LIMIT 1",
203
+ (memory_id, profile_id),
204
+ )
205
+ if not siblings:
206
+ db.execute(
207
+ "DELETE FROM memories WHERE memory_id = ? AND profile_id = ?",
208
+ (memory_id, profile_id),
209
+ )
210
+ except Exception:
211
+ logger.debug("memory delete by id skipped for %s", fact_id[:16])
212
+ else:
213
+ db.delete_memory_for_fact(fact_id, profile_id)
214
+
215
+ # Persistent BM25 tokens
216
+ db.delete_bm25_tokens_for_fact(fact_id)
217
+
218
+ # FTS expansion entry
219
+ db.upsert_fact_expansion(fact_id, "")
220
+
221
+ # Knowledge graph edges
222
+ db.delete_graph_edges_for_fact(fact_id)
223
+
224
+ # Memory scenes — remove fact_id from JSON arrays
225
+ db.remove_fact_from_scenes(fact_id, profile_id)
226
+
227
+ # Fact context description
228
+ db.delete_fact_context(fact_id)
229
+
230
+ # Context cache
231
+ _invalidate_context_cache_for_fact(engine, fact_id)
232
+
233
+ # BM25 live channel (in-memory eviction)
234
+ retrieval = getattr(engine, "_retrieval_engine", None)
235
+ bm25 = getattr(retrieval, "_bm25", None) if retrieval else None
236
+ if bm25 is not None and hasattr(bm25, "remove_fact"):
237
+ bm25.remove_fact(fact_id)
238
+
239
+ # Vector store + ANN index
240
+ if retrieval is not None:
241
+ _sync_vector_ann(retrieval, fact_id, profile_id, None, operation="delete")
242
+
243
+ try:
244
+ from superlocalmemory.core.backend_orchestrator import get_orchestrator
245
+ orchestrator = get_orchestrator()
246
+ if orchestrator is not None:
247
+ orchestrator.sync_deleted_fact(fact_id)
248
+ except Exception:
249
+ logger.warning("Derived projection deletion sync failed for %s", fact_id[:16])
250
+
251
+
252
+ def _canonical_present(engine: Any, fact_id: str, profile_id: str) -> bool:
253
+ return bool(engine._db.execute(
254
+ "SELECT 1 FROM atomic_facts WHERE fact_id = ? AND profile_id = ? LIMIT 1",
255
+ (fact_id, profile_id),
256
+ ))
257
+
258
+
259
+ def _memory_present(engine: Any, memory_id: str, profile_id: str) -> bool:
260
+ return bool(engine._db.execute(
261
+ "SELECT 1 FROM memories WHERE memory_id = ? AND profile_id = ? LIMIT 1",
262
+ (memory_id, profile_id),
263
+ ))
264
+
265
+
266
+ def _memory_has_facts(engine: Any, memory_id: str, profile_id: str) -> bool:
267
+ return bool(engine._db.execute(
268
+ "SELECT 1 FROM atomic_facts WHERE memory_id = ? AND profile_id = ? LIMIT 1",
269
+ (memory_id, profile_id),
270
+ ))
271
+
272
+
273
+ def _finalize_erasure(
274
+ engine: Any,
275
+ service: Any,
276
+ op_ctx: Any,
277
+ fact_id: str,
278
+ profile_id: str,
279
+ memory_id: str | None,
280
+ erasure_id: str,
281
+ requested_at: float,
282
+ *,
283
+ requested_by: str,
284
+ tombstoned: bool,
285
+ ) -> dict[str, Any]:
286
+ try:
287
+ from superlocalmemory.core.transactions import ErasureProofRecord
288
+ from superlocalmemory.core.transactions.erasure import tombstone_memory_id
289
+
290
+ canonical_absent = not _canonical_present(engine, fact_id, profile_id)
291
+ extra = [ErasureProofRecord(
292
+ owner="canonical", erased=canonical_absent, checksum="",
293
+ residue=() if canonical_absent else (fact_id,),
294
+ )]
295
+ if memory_id:
296
+ memory_ok = (
297
+ not _memory_present(engine, memory_id, profile_id)
298
+ or _memory_has_facts(engine, memory_id, profile_id)
299
+ )
300
+ extra.append(ErasureProofRecord(
301
+ owner="memory", erased=memory_ok, checksum="",
302
+ residue=() if memory_ok else (memory_id,),
303
+ ))
304
+ stored_mid = tombstone_memory_id(engine._db, profile_id, fact_id)
305
+ if stored_mid and stored_mid != memory_id:
306
+ stored_mid_ok = (
307
+ not _memory_present(engine, stored_mid, profile_id)
308
+ or _memory_has_facts(engine, stored_mid, profile_id)
309
+ )
310
+ if not stored_mid_ok:
311
+ extra.append(ErasureProofRecord(
312
+ owner="memory_provenance", erased=False, checksum="",
313
+ residue=(stored_mid,),
314
+ ))
315
+ receipt = service.finalize(
316
+ engine._db, op_ctx,
317
+ subject_type="fact", subject_id=fact_id,
318
+ requested_by=requested_by, requested_at=requested_at,
319
+ extra_proofs=extra,
320
+ )
321
+ verified = receipt.all_erased and receipt.persisted and tombstoned
322
+ if not verified:
323
+ logger.error(
324
+ "Erasure not verified for %s: state=%s persisted=%s "
325
+ "tombstoned=%s residue=%s",
326
+ fact_id[:16], receipt.state, receipt.persisted, tombstoned,
327
+ [p.owner for p in receipt.proofs if not p.erased],
328
+ )
329
+ return {
330
+ "erasure_id": erasure_id,
331
+ "erasure_verified": verified,
332
+ "erasure_state": receipt.state,
333
+ }
334
+ except Exception as exc:
335
+ logger.error("Erasure finalize failed for %s: %s", fact_id[:16], exc)
336
+ return {
337
+ "erasure_id": erasure_id,
338
+ "erasure_verified": False,
339
+ "erasure_state": "FAILED",
340
+ }
341
+
342
+
39
343
  def delete_fact_authorized(
40
344
  engine: Any,
41
345
  fact_id: str,
@@ -46,6 +350,19 @@ def delete_fact_authorized(
46
350
  idempotency_key: str | None = None,
47
351
  ) -> dict[str, Any]:
48
352
  """Authorize, delete one profile-owned fact, then emit post hooks."""
353
+ import time as _time
354
+ import uuid
355
+
356
+ from superlocalmemory.core.transactions import OperationContext
357
+ from superlocalmemory.core.transactions.concrete_owners import (
358
+ build_erasure_service,
359
+ )
360
+ from superlocalmemory.core.transactions.erasure import (
361
+ is_tombstoned,
362
+ tombstone_memory_id,
363
+ )
364
+ from superlocalmemory.storage.erasure_fence import clear_erasing, mark_erasing
365
+
49
366
  profile_id, context = _context(
50
367
  engine,
51
368
  "delete",
@@ -53,40 +370,122 @@ def delete_fact_authorized(
53
370
  trusted_actor_id=trusted_actor_id,
54
371
  source_agent_id=source_agent_id,
55
372
  )
56
- if canonical_runtime is not None:
57
- result = dict(canonical_runtime.delete_fact(
58
- profile_id, fact_id, idempotency_key=idempotency_key,
59
- ))
60
- if not result.get("ok"):
61
- return {"ok": False, "error": f"Memory {fact_id} not found"}
62
- content_preview = str(result.get("content_preview", ""))
63
- else:
64
- rows = engine._db.execute(
65
- "SELECT content FROM atomic_facts "
66
- "WHERE fact_id = ? AND profile_id = ? LIMIT 1",
67
- (fact_id, profile_id),
68
- )
69
- if not rows:
373
+
374
+ rows = engine._db.execute(
375
+ "SELECT content, memory_id FROM atomic_facts "
376
+ "WHERE fact_id = ? AND profile_id = ? LIMIT 1",
377
+ (fact_id, profile_id),
378
+ )
379
+ exists = bool(rows)
380
+ content_preview = dict(rows[0]).get("content", "")[:80] if exists else ""
381
+ memory_id = dict(rows[0]).get("memory_id") if exists else None
382
+
383
+ erasure_id = uuid.uuid4().hex
384
+ requested_at = _time.time()
385
+ op_ctx = OperationContext(
386
+ operation_id=erasure_id, profile_id=profile_id,
387
+ subject_id=fact_id, fact_ids=(fact_id,),
388
+ )
389
+ service = build_erasure_service(engine)
390
+
391
+ # A tombstoned fact whose canonical row is already gone is a partially
392
+ # completed prior erasure (e.g. a transient raw-memory delete failure left
393
+ # the source memory behind). Resume cleanup from the stored memory_id
394
+ # instead of reporting the fact "not found".
395
+ resuming = False
396
+ if not exists:
397
+ with engine._db.raw_connection() as _conn:
398
+ resuming = is_tombstoned(_conn, profile_id, fact_id)
399
+ if not resuming:
70
400
  return {"ok": False, "error": f"Memory {fact_id} not found"}
71
- content_preview = dict(rows[0]).get("content", "")[:80]
72
- engine._db.delete_fact(fact_id, profile_id=profile_id)
401
+ memory_id = tombstone_memory_id(engine._db, profile_id, fact_id)
402
+
403
+ remove_result = None
404
+ if exists:
405
+ try:
406
+ remove_result = service.remove(engine._db, op_ctx, memory_id=memory_id)
407
+ except Exception as exc:
408
+ logger.error("Erasure remove failed for %s: %s", fact_id[:16], exc)
409
+ if (
410
+ remove_result is None
411
+ or not remove_result.spine_ok
412
+ or not remove_result.tombstoned
413
+ ):
414
+ erasure = _finalize_erasure(
415
+ engine, service, op_ctx, fact_id, profile_id, memory_id,
416
+ erasure_id, requested_at, requested_by=trusted_actor_id,
417
+ tombstoned=bool(remove_result and remove_result.tombstoned),
418
+ )
419
+ logger.error(
420
+ "Erasure blocked (residue or unverified tombstone) for %s — "
421
+ "canonical retained",
422
+ fact_id[:16],
423
+ )
424
+ return {
425
+ "ok": False,
426
+ "error": "erasure incomplete: projection residue",
427
+ "retryable": True,
428
+ **erasure,
429
+ }
430
+
431
+ # Fence the fact for the purge window so a concurrent materializer cannot
432
+ # re-write its projections after the durable tombstone is committed. The
433
+ # tombstone remains the cross-process signal; this is the in-process guard.
434
+ mark_erasing(profile_id, fact_id)
73
435
  try:
74
- from superlocalmemory.core.backend_orchestrator import get_orchestrator
75
- orchestrator = get_orchestrator()
76
- if orchestrator is not None:
77
- orchestrator.sync_deleted_fact(fact_id)
78
- except Exception:
79
- logger.warning("Derived projection deletion sync failed for %s", fact_id[:16])
80
- engine._hooks.run_post("delete", context)
81
- logger.info(
82
- "DELETE fact_id=%s actor=%s source_agent=%s content=%s",
83
- fact_id[:16], trusted_actor_id, source_agent_id, content_preview,
84
- )
85
- return {
86
- "ok": True,
87
- "deleted": fact_id,
88
- "content_preview": content_preview,
89
- }
436
+ if exists:
437
+ if canonical_runtime is not None:
438
+ result = dict(canonical_runtime.delete_fact(
439
+ profile_id, fact_id, idempotency_key=idempotency_key,
440
+ ))
441
+ if not result.get("ok"):
442
+ return {"ok": False, "error": f"Memory {fact_id} not found"}
443
+ if not content_preview:
444
+ content_preview = str(result.get("content_preview", ""))
445
+ else:
446
+ engine._db.delete_fact(fact_id, profile_id=profile_id)
447
+
448
+ # Purge projections for a fresh delete and re-run (idempotently) for a
449
+ # resumed cleanup so an orphaned source memory is reclaimed on retry.
450
+ _purge_delete_projections(engine, fact_id, profile_id, memory_id=memory_id)
451
+
452
+ if exists:
453
+ tombstoned = bool(remove_result and remove_result.tombstoned)
454
+ else:
455
+ with engine._db.raw_connection() as _conn:
456
+ tombstoned = is_tombstoned(_conn, profile_id, fact_id)
457
+
458
+ erasure = _finalize_erasure(
459
+ engine, service, op_ctx, fact_id, profile_id, memory_id, erasure_id,
460
+ requested_at, requested_by=trusted_actor_id, tombstoned=tombstoned,
461
+ )
462
+
463
+ engine._hooks.run_post("delete", context)
464
+ logger.info(
465
+ "DELETE fact_id=%s actor=%s source_agent=%s content=%s",
466
+ fact_id[:16], trusted_actor_id, source_agent_id, content_preview,
467
+ )
468
+ erasure_verified = bool(erasure.get("erasure_verified", False))
469
+ if not erasure_verified:
470
+ logger.error(
471
+ "Erasure unverified for %s — returning retryable failure",
472
+ fact_id[:16],
473
+ )
474
+ return {
475
+ "ok": False,
476
+ "retryable": True,
477
+ "deleted": fact_id,
478
+ "content_preview": content_preview,
479
+ **erasure,
480
+ }
481
+ return {
482
+ "ok": True,
483
+ "deleted": fact_id,
484
+ "content_preview": content_preview,
485
+ **erasure,
486
+ }
487
+ finally:
488
+ clear_erasing(profile_id, fact_id)
90
489
 
91
490
 
92
491
  def update_fact_authorized(
@@ -99,7 +498,7 @@ def update_fact_authorized(
99
498
  canonical_runtime: Any | None = None,
100
499
  idempotency_key: str | None = None,
101
500
  ) -> dict[str, Any]:
102
- """Authorize a fact update and refresh semantic and lexical indexes."""
501
+ """Authorize a fact correction and converge every derived projection."""
103
502
  if not content or not content.strip():
104
503
  return {"ok": False, "error": "content cannot be empty"}
105
504
  content = content.strip()
@@ -120,6 +519,7 @@ def update_fact_authorized(
120
519
  return {"ok": False, "error": f"Memory {fact_id} not found"}
121
520
  old_content = dict(rows[0]).get("content", "")[:80]
122
521
  updates: dict[str, Any] = {"content": content}
522
+ embedding: list[float] | None = None
123
523
  if engine._embedder:
124
524
  try:
125
525
  embedding = engine._embedder.embed(content)
@@ -143,17 +543,10 @@ def update_fact_authorized(
143
543
  return {"ok": False, "error": f"Memory {fact_id} not found"}
144
544
  else:
145
545
  engine._db.update_fact(fact_id, updates, profile_id=profile_id)
146
- try:
147
- from superlocalmemory.core.backend_orchestrator import get_orchestrator
148
- orchestrator = get_orchestrator()
149
- if orchestrator is not None:
150
- orchestrator.sync_changed_fact(fact_id)
151
- except Exception:
152
- logger.warning("Derived projection update sync failed for %s", fact_id[:16])
153
- retrieval = getattr(engine, "_retrieval_engine", None)
154
- bm25 = getattr(retrieval, "_bm25", None) if retrieval else None
155
- if bm25:
156
- bm25.add(fact_id, content, profile_id)
546
+
547
+ # Converge every derived projection to the corrected content
548
+ _converge_update_projections(engine, fact_id, content, profile_id, embedding)
549
+
157
550
  engine._hooks.run_post("update", context)
158
551
  logger.info(
159
552
  "UPDATE fact_id=%s actor=%s source_agent=%s old=%s new=%s",
@@ -0,0 +1,92 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+
4
+ """Declarative per-operation policy record for the V4 admission layer.
5
+
6
+ OperationPolicy is a plain frozen dataclass — a pure data declaration with no
7
+ behaviour. The OperationPolicyRegistry owns all evaluation logic. A policy
8
+ declares WHAT is required; the registry decides WHAT to DO when requirements
9
+ are not met.
10
+
11
+ Part of SuperLocalMemory V4 | Phase 4: Admission/Policy Layer
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import FrozenSet
18
+
19
+ from superlocalmemory.core.actor_context import ActorRole, Transport
20
+ from superlocalmemory.core.operation_request import OperationKind
21
+
22
+ # Pre-compute the frozenset of all transports once; used as the default for
23
+ # policies that allow every transport (most operations).
24
+ _ALL_TRANSPORTS: frozenset[Transport] = frozenset(Transport)
25
+
26
+ # Sensitive/administrative operations that are restricted to fewer transports
27
+ # by default. The registry may override these at construction time.
28
+ _ADMIN_TRANSPORTS: frozenset[Transport] = frozenset({
29
+ Transport.CLI,
30
+ Transport.INTERNAL,
31
+ Transport.DASHBOARD,
32
+ })
33
+ _MESH_TRANSPORTS: frozenset[Transport] = frozenset({
34
+ Transport.MESH,
35
+ Transport.MCP,
36
+ Transport.HTTP,
37
+ Transport.INTERNAL,
38
+ })
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class OperationPolicy:
43
+ """Declarative policy for one operation kind.
44
+
45
+ The registry stores one OperationPolicy per OperationKind (and
46
+ optionally per transport when transport-specific overrides are needed,
47
+ though the V4 default table uses one per kind).
48
+
49
+ Field semantics
50
+ ---------------
51
+ required_roles Actor must hold at least ONE of these roles.
52
+ Empty frozenset → deny all (explicit deny policy).
53
+ required_authentication Actor.is_authenticated must be True.
54
+ allowed_transports Actor.transport must be in this set.
55
+ requires_schema_capability Non-empty → DB must expose this capability.
56
+ max_payload_bytes Advisory only — payload > this is ANNOTATED, not
57
+ rejected (double-rejection is avoided; the ingest
58
+ gate already enforces the 1 MiB hard cap).
59
+ deadline_budget_ms Advisory request deadline.
60
+ audit_level "none" | "standard" | "full" — governs log detail.
61
+ redaction_policy "none" | "scrub" | "full" — governs PII scrub.
62
+ resource_ownership_check Whether the coordinator must verify ownership.
63
+ allow_cross_profile Whether the actor may target a non-active profile.
64
+ fail_closed_in_company_mode Informational — the registry uses the ``mode``
65
+ argument to evaluate(), not this field, for
66
+ unknown-kind fallback behaviour.
67
+ """
68
+
69
+ kind: OperationKind
70
+ required_roles: FrozenSet[ActorRole] = field(
71
+ default_factory=lambda: frozenset({ActorRole.OWNER})
72
+ )
73
+ required_authentication: bool = True
74
+ allowed_transports: FrozenSet[Transport] = field(
75
+ default_factory=lambda: _ALL_TRANSPORTS
76
+ )
77
+ requires_schema_capability: str = ""
78
+ max_payload_bytes: int = 1_048_576 # 1 MiB — advisory only
79
+ deadline_budget_ms: int = 30_000
80
+ audit_level: str = "standard" # "none" | "standard" | "full"
81
+ redaction_policy: str = "scrub" # "none" | "scrub" | "full"
82
+ resource_ownership_check: bool = True
83
+ allow_cross_profile: bool = False
84
+ fail_closed_in_company_mode: bool = True # informational; see docstring
85
+
86
+
87
+ __all__ = [
88
+ "OperationPolicy",
89
+ "_ALL_TRANSPORTS",
90
+ "_ADMIN_TRANSPORTS",
91
+ "_MESH_TRANSPORTS",
92
+ ]