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
@@ -0,0 +1,825 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import hmac as _hmac_mod
8
+ import json
9
+ import logging
10
+ import time
11
+ from collections.abc import Callable, Iterable, Mapping
12
+ from dataclasses import dataclass
13
+
14
+ _RECEIPT_V1: int = 1
15
+ _RECEIPT_V2: int = 2
16
+
17
+ from superlocalmemory.core.transactions.obligations import ObligationLedger
18
+ from superlocalmemory.core.transactions.owners import (
19
+ ObligationKind,
20
+ ObligationState,
21
+ OperationContext,
22
+ ProjectionOwner,
23
+ )
24
+
25
+ logger = logging.getLogger("superlocalmemory.core.transactions.erasure")
26
+
27
+ MAX_ERASE_ATTEMPTS = 10
28
+
29
+ VALID_SUBJECT_TYPES = frozenset({"fact", "entity", "profile"})
30
+
31
+ AuditLogger = Callable[[Mapping[str, object]], None]
32
+
33
+
34
+ class ErasureState:
35
+ COMPLETE = "COMPLETE"
36
+ FAILED = "FAILED"
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class ErasureProofRecord:
41
+ owner: str
42
+ erased: bool
43
+ checksum: str
44
+ residue: tuple[str, ...] = ()
45
+
46
+ def as_dict(self) -> dict[str, object]:
47
+ return {
48
+ "owner": self.owner,
49
+ "erased": bool(self.erased),
50
+ "checksum": self.checksum,
51
+ "residue": list(self.residue),
52
+ }
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class RemoveResult:
57
+ proofs: tuple["ErasureProofRecord", ...]
58
+ spine_ok: bool
59
+ tombstoned: bool
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class ErasureReceipt:
64
+ erasure_id: str
65
+ profile_id: str
66
+ subject_type: str
67
+ subject_id: str
68
+ requested_by: str
69
+ fact_count: int
70
+ state: str
71
+ all_erased: bool
72
+ proofs: tuple[ErasureProofRecord, ...]
73
+ audit_hash: str
74
+ requested_at: float
75
+ completed_at: float
76
+ persisted: bool
77
+
78
+
79
+ def _proof_dicts(proofs: Iterable[ErasureProofRecord]) -> list[dict[str, object]]:
80
+ return sorted((p.as_dict() for p in proofs), key=lambda d: d["owner"])
81
+
82
+
83
+ def compute_erasure_hash(
84
+ *,
85
+ erasure_id: str,
86
+ profile_id: str,
87
+ subject_type: str,
88
+ subject_id: str,
89
+ requested_by: str,
90
+ fact_count: int,
91
+ state: str,
92
+ all_erased: bool,
93
+ evidence_json: str,
94
+ requested_at: float,
95
+ completed_at: float,
96
+ ) -> str:
97
+ """Unkeyed SHA-256 hash for erasure receipts (v1, backward-compat)."""
98
+ canonical = _erasure_canonical(
99
+ erasure_id=erasure_id,
100
+ profile_id=profile_id,
101
+ subject_type=subject_type,
102
+ subject_id=subject_id,
103
+ requested_by=requested_by,
104
+ fact_count=fact_count,
105
+ state=state,
106
+ all_erased=all_erased,
107
+ evidence_json=evidence_json,
108
+ requested_at=requested_at,
109
+ completed_at=completed_at,
110
+ )
111
+ return hashlib.sha256(canonical).hexdigest()
112
+
113
+
114
+ def _erasure_canonical(
115
+ *,
116
+ erasure_id: str,
117
+ profile_id: str,
118
+ subject_type: str,
119
+ subject_id: str,
120
+ requested_by: str,
121
+ fact_count: int,
122
+ state: str,
123
+ all_erased: bool,
124
+ evidence_json: str,
125
+ requested_at: float,
126
+ completed_at: float,
127
+ receipt_version: int | None = None,
128
+ ) -> bytes:
129
+ """Produce the deterministic byte representation of the erasure receipt envelope.
130
+
131
+ When ``receipt_version`` is provided (v2+), it is bound into the canonical
132
+ bytes so a downgrade from v2 → v1 is detected by the MAC mismatch.
133
+ """
134
+ envelope: dict[str, object] = {
135
+ "erasure_id": erasure_id,
136
+ "profile_id": profile_id,
137
+ "subject_type": subject_type,
138
+ "subject_id": subject_id,
139
+ "requested_by": requested_by,
140
+ "fact_count": fact_count,
141
+ "state": state,
142
+ "all_erased": bool(all_erased),
143
+ "evidence": evidence_json,
144
+ "requested_at": repr(float(requested_at)),
145
+ "completed_at": repr(float(completed_at)),
146
+ }
147
+ if receipt_version is not None:
148
+ envelope["receipt_version"] = receipt_version
149
+ return json.dumps(
150
+ envelope, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
151
+ ).encode("utf-8")
152
+
153
+
154
+ def compute_erasure_hmac(
155
+ *,
156
+ erasure_id: str,
157
+ profile_id: str,
158
+ subject_type: str,
159
+ subject_id: str,
160
+ requested_by: str,
161
+ fact_count: int,
162
+ state: str,
163
+ all_erased: bool,
164
+ evidence_json: str,
165
+ requested_at: float,
166
+ completed_at: float,
167
+ key: bytes | None = None,
168
+ ) -> str:
169
+ """HMAC-SHA256 keyed hash for erasure receipts (v2).
170
+
171
+ ``key`` is injected in tests; production code passes ``key=None`` to
172
+ auto-derive from the installation key.
173
+ """
174
+ from superlocalmemory.core.transactions.manifest_key import (
175
+ compute_hmac,
176
+ derive_receipt_hmac_key,
177
+ )
178
+
179
+ actual_key = key if key is not None else derive_receipt_hmac_key()
180
+ canonical = _erasure_canonical(
181
+ erasure_id=erasure_id,
182
+ profile_id=profile_id,
183
+ subject_type=subject_type,
184
+ subject_id=subject_id,
185
+ requested_by=requested_by,
186
+ fact_count=fact_count,
187
+ state=state,
188
+ all_erased=all_erased,
189
+ evidence_json=evidence_json,
190
+ requested_at=requested_at,
191
+ completed_at=completed_at,
192
+ receipt_version=_RECEIPT_V2,
193
+ )
194
+ return compute_hmac(actual_key, canonical)
195
+
196
+
197
+ class ErasureService:
198
+ def __init__(
199
+ self,
200
+ owners: Mapping[str, ProjectionOwner],
201
+ *,
202
+ ledger: ObligationLedger | None = None,
203
+ audit_logger: AuditLogger | None = None,
204
+ ) -> None:
205
+ self._owners: dict[str, ProjectionOwner] = dict(owners)
206
+ self._ledger = ledger or ObligationLedger()
207
+ self._audit_logger = audit_logger
208
+
209
+ def erase(
210
+ self,
211
+ db: object,
212
+ context: OperationContext,
213
+ *,
214
+ subject_type: str,
215
+ subject_id: str,
216
+ requested_by: str = "",
217
+ ) -> ErasureReceipt:
218
+ requested_at = time.time()
219
+ remove_result = self.remove(db, context, memory_id=None)
220
+ return self.finalize(
221
+ db, context,
222
+ subject_type=subject_type, subject_id=subject_id,
223
+ requested_by=requested_by, requested_at=requested_at,
224
+ remove_result=remove_result,
225
+ )
226
+
227
+ def remove(
228
+ self, db: object, context: OperationContext, *, memory_id: str | None = None,
229
+ ) -> RemoveResult:
230
+ self._record_obligations(db, context)
231
+ status = write_tombstones_status(
232
+ db, context.profile_id, tuple(sorted(set(context.fact_ids))),
233
+ context.operation_id, time.time(), memory_id,
234
+ )
235
+ if status == TOMBSTONE_CONFLICT:
236
+ # Fail closed: do not delete owner projections under a provenance
237
+ # conflict — the resurrection guard cannot be trusted here.
238
+ return RemoveResult(proofs=(), spine_ok=False, tombstoned=False)
239
+ proofs = [self._erase_owner(db, context, name) for name in sorted(self._owners)]
240
+ owners_ok = bool(proofs) and all(p.erased for p in proofs)
241
+ # A missing tombstone table (older schema) or an empty fact set does not
242
+ # gate; a real write error on a modern store does.
243
+ tombstone_ok = status in (TOMBSTONE_WRITTEN, TOMBSTONE_ABSENT)
244
+ return RemoveResult(
245
+ proofs=tuple(proofs),
246
+ spine_ok=owners_ok and tombstone_ok,
247
+ tombstoned=(status == TOMBSTONE_WRITTEN),
248
+ )
249
+
250
+ def finalize(
251
+ self,
252
+ db: object,
253
+ context: OperationContext,
254
+ *,
255
+ subject_type: str,
256
+ subject_id: str,
257
+ requested_by: str = "",
258
+ requested_at: float | None = None,
259
+ extra_proofs: Iterable[ErasureProofRecord] = (),
260
+ remove_result: "RemoveResult | None" = None,
261
+ ) -> ErasureReceipt:
262
+ if subject_type not in VALID_SUBJECT_TYPES:
263
+ raise ValueError(f"invalid subject_type: {subject_type!r}")
264
+ requested_at = time.time() if requested_at is None else requested_at
265
+ proofs: list[ErasureProofRecord] = [
266
+ self._prove_owner(context, name) for name in sorted(self._owners)
267
+ ]
268
+ proofs.extend(extra_proofs)
269
+ owners_erased = bool(proofs) and all(p.erased for p in proofs)
270
+ # Fail closed: COMPLETE requires proven owner erasure AND, when the
271
+ # caller threads its remove() result, a clean tombstone step (no
272
+ # provenance conflict or write error on a modern store).
273
+ all_erased = owners_erased and (
274
+ remove_result.spine_ok if remove_result is not None else True
275
+ )
276
+ state = ErasureState.COMPLETE if all_erased else ErasureState.FAILED
277
+ evidence_json = json.dumps(
278
+ {"fact_ids": sorted(set(context.fact_ids)), "proofs": _proof_dicts(proofs)},
279
+ sort_keys=True,
280
+ separators=(",", ":"),
281
+ ensure_ascii=False,
282
+ )
283
+ fact_count = len(set(context.fact_ids))
284
+ completed_at = time.time()
285
+ receipt_version = _receipt_version_from_db(db)
286
+ if receipt_version >= _RECEIPT_V2:
287
+ audit_hash = compute_erasure_hmac(
288
+ erasure_id=context.operation_id,
289
+ profile_id=context.profile_id,
290
+ subject_type=subject_type,
291
+ subject_id=subject_id,
292
+ requested_by=requested_by,
293
+ fact_count=fact_count,
294
+ state=state,
295
+ all_erased=all_erased,
296
+ evidence_json=evidence_json,
297
+ requested_at=requested_at,
298
+ completed_at=completed_at,
299
+ )
300
+ else:
301
+ audit_hash = compute_erasure_hash(
302
+ erasure_id=context.operation_id,
303
+ profile_id=context.profile_id,
304
+ subject_type=subject_type,
305
+ subject_id=subject_id,
306
+ requested_by=requested_by,
307
+ fact_count=fact_count,
308
+ state=state,
309
+ all_erased=all_erased,
310
+ evidence_json=evidence_json,
311
+ requested_at=requested_at,
312
+ completed_at=completed_at,
313
+ )
314
+ persisted = self._persist(
315
+ db,
316
+ erasure_id=context.operation_id,
317
+ profile_id=context.profile_id,
318
+ subject_type=subject_type,
319
+ subject_id=subject_id,
320
+ requested_by=requested_by,
321
+ fact_count=fact_count,
322
+ state=state,
323
+ all_erased=all_erased,
324
+ evidence_json=evidence_json,
325
+ audit_hash=audit_hash,
326
+ requested_at=requested_at,
327
+ completed_at=completed_at,
328
+ fact_ids=tuple(sorted(set(context.fact_ids))),
329
+ )
330
+ self._emit_audit(
331
+ erasure_id=context.operation_id,
332
+ profile_id=context.profile_id,
333
+ subject_type=subject_type,
334
+ subject_id=subject_id,
335
+ requested_by=requested_by,
336
+ state=state,
337
+ audit_hash=audit_hash,
338
+ )
339
+ return ErasureReceipt(
340
+ erasure_id=context.operation_id,
341
+ profile_id=context.profile_id,
342
+ subject_type=subject_type,
343
+ subject_id=subject_id,
344
+ requested_by=requested_by,
345
+ fact_count=fact_count,
346
+ state=state,
347
+ all_erased=all_erased,
348
+ proofs=tuple(proofs),
349
+ audit_hash=audit_hash,
350
+ requested_at=requested_at,
351
+ completed_at=completed_at,
352
+ persisted=persisted,
353
+ )
354
+
355
+ def _prove_owner(
356
+ self, context: OperationContext, name: str,
357
+ ) -> ErasureProofRecord:
358
+ owner = self._owners[name]
359
+ try:
360
+ proof = owner.prove_erased(context)
361
+ erased = bool(proof.erased)
362
+ checksum = proof.checksum or ""
363
+ raw_residue = dict(proof.detail).get("residue")
364
+ residue = (
365
+ tuple(str(x) for x in raw_residue)
366
+ if isinstance(raw_residue, list) else ()
367
+ )
368
+ except Exception as exc: # noqa: BLE001
369
+ erased = False
370
+ checksum = ""
371
+ residue = ()
372
+ logger.warning("erasure prove failed for %s: %s", name, _err(exc))
373
+ return ErasureProofRecord(
374
+ owner=name, erased=erased, checksum=checksum, residue=residue,
375
+ )
376
+
377
+ def _record_obligations(self, db: object, context: OperationContext) -> None:
378
+ try:
379
+ with db.raw_connection() as conn:
380
+ for name in sorted(self._owners):
381
+ self._ledger.record(conn, context, name, ObligationKind.ERASE)
382
+ except Exception as exc: # noqa: BLE001
383
+ logger.warning("erasure obligation record skipped: %s", _err(exc))
384
+
385
+ def _write_tombstones(
386
+ self,
387
+ db: object,
388
+ profile_id: str,
389
+ fact_ids: tuple[str, ...],
390
+ erasure_id: str,
391
+ created_at: float,
392
+ memory_id: str | None = None,
393
+ ) -> None:
394
+ write_tombstones(
395
+ db, profile_id, fact_ids, erasure_id, created_at, memory_id,
396
+ )
397
+
398
+ def _erase_owner(
399
+ self, db: object, context: OperationContext, name: str,
400
+ ) -> ErasureProofRecord:
401
+ owner = self._owners[name]
402
+ residue: tuple[str, ...] = ()
403
+ try:
404
+ proof = owner.erase(context)
405
+ erased = bool(proof.erased)
406
+ checksum = proof.checksum or ""
407
+ detail = {"phase": "erase", **dict(proof.detail)}
408
+ raw_residue = detail.get("residue")
409
+ if isinstance(raw_residue, list):
410
+ residue = tuple(str(x) for x in raw_residue)
411
+ except Exception as exc: # noqa: BLE001
412
+ erased = False
413
+ checksum = ""
414
+ detail = {"phase": "erase", "error": _err(exc)}
415
+ try:
416
+ with db.raw_connection() as conn:
417
+ self._ledger.mark(
418
+ conn,
419
+ context.operation_id,
420
+ name,
421
+ ObligationKind.ERASE,
422
+ ObligationState.ERASED if erased else ObligationState.FAILED,
423
+ checksum=checksum or None,
424
+ detail=detail,
425
+ bump_attempts=True,
426
+ )
427
+ except Exception as exc: # noqa: BLE001
428
+ logger.warning("erasure obligation mark skipped for %s: %s", name, _err(exc))
429
+ return ErasureProofRecord(
430
+ owner=name, erased=erased, checksum=checksum, residue=residue,
431
+ )
432
+
433
+ def _persist(
434
+ self,
435
+ db: object,
436
+ *,
437
+ erasure_id: str,
438
+ profile_id: str,
439
+ subject_type: str,
440
+ subject_id: str,
441
+ requested_by: str,
442
+ fact_count: int,
443
+ state: str,
444
+ all_erased: bool,
445
+ evidence_json: str,
446
+ audit_hash: str,
447
+ requested_at: float,
448
+ completed_at: float,
449
+ fact_ids: tuple[str, ...],
450
+ ) -> bool:
451
+ try:
452
+ with db.raw_connection() as conn:
453
+ if not _table_exists(conn, "erasure_receipts"):
454
+ return False
455
+ version = _receipt_version_supported(conn)
456
+ if version >= _RECEIPT_V2:
457
+ conn.execute(
458
+ "INSERT INTO erasure_receipts "
459
+ "(erasure_id, profile_id, subject_type, subject_id, "
460
+ "requested_by, fact_count, state, all_erased, "
461
+ "owner_evidence_json, audit_hash, receipt_version, "
462
+ "requested_at, completed_at) "
463
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
464
+ "ON CONFLICT(erasure_id) DO UPDATE SET "
465
+ "state = excluded.state, "
466
+ "all_erased = excluded.all_erased, "
467
+ "owner_evidence_json = excluded.owner_evidence_json, "
468
+ "audit_hash = excluded.audit_hash, "
469
+ "receipt_version = excluded.receipt_version, "
470
+ "requested_at = excluded.requested_at, "
471
+ "completed_at = excluded.completed_at",
472
+ (
473
+ erasure_id, profile_id, subject_type, subject_id,
474
+ requested_by, fact_count, state,
475
+ 1 if all_erased else 0, evidence_json, audit_hash,
476
+ _RECEIPT_V2, requested_at, completed_at,
477
+ ),
478
+ )
479
+ else:
480
+ conn.execute(
481
+ "INSERT INTO erasure_receipts "
482
+ "(erasure_id, profile_id, subject_type, subject_id, "
483
+ "requested_by, fact_count, state, all_erased, "
484
+ "owner_evidence_json, audit_hash, requested_at, completed_at) "
485
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
486
+ "ON CONFLICT(erasure_id) DO UPDATE SET "
487
+ "state = excluded.state, "
488
+ "all_erased = excluded.all_erased, "
489
+ "owner_evidence_json = excluded.owner_evidence_json, "
490
+ "audit_hash = excluded.audit_hash, "
491
+ "requested_at = excluded.requested_at, "
492
+ "completed_at = excluded.completed_at",
493
+ (
494
+ erasure_id, profile_id, subject_type, subject_id,
495
+ requested_by, fact_count, state,
496
+ 1 if all_erased else 0, evidence_json, audit_hash,
497
+ requested_at, completed_at,
498
+ ),
499
+ )
500
+ conn.commit()
501
+ return True
502
+ except Exception as exc: # noqa: BLE001
503
+ logger.warning("erasure receipt persist skipped: %s", _err(exc))
504
+ return False
505
+
506
+ def _emit_audit(
507
+ self,
508
+ *,
509
+ erasure_id: str,
510
+ profile_id: str,
511
+ subject_type: str,
512
+ subject_id: str,
513
+ requested_by: str,
514
+ state: str,
515
+ audit_hash: str,
516
+ ) -> None:
517
+ if self._audit_logger is None:
518
+ return
519
+ try:
520
+ self._audit_logger({
521
+ "erasure_id": erasure_id,
522
+ "profile_id": profile_id,
523
+ "subject_type": subject_type,
524
+ "subject_id": subject_id,
525
+ "requested_by": requested_by,
526
+ "state": state,
527
+ "audit_hash": audit_hash,
528
+ })
529
+ except Exception as exc: # noqa: BLE001
530
+ logger.warning("erasure audit-chain emit skipped: %s", _err(exc))
531
+
532
+
533
+ def fetch_receipt(
534
+ conn: object, erasure_id: str, *, profile_id: str | None = None,
535
+ ) -> ErasureReceipt | None:
536
+ predicate = "erasure_id = ?"
537
+ params: list[object] = [erasure_id]
538
+ if profile_id is not None:
539
+ predicate += " AND profile_id = ?"
540
+ params.append(profile_id)
541
+ row = conn.execute(
542
+ "SELECT erasure_id, profile_id, subject_type, subject_id, requested_by, "
543
+ "fact_count, state, all_erased, owner_evidence_json, audit_hash, "
544
+ f"requested_at, completed_at FROM erasure_receipts WHERE {predicate}",
545
+ tuple(params),
546
+ ).fetchone()
547
+ if row is None:
548
+ return None
549
+ return ErasureReceipt(
550
+ erasure_id=row[0],
551
+ profile_id=row[1],
552
+ subject_type=row[2],
553
+ subject_id=row[3],
554
+ requested_by=row[4],
555
+ fact_count=int(row[5]),
556
+ state=row[6],
557
+ all_erased=bool(row[7]),
558
+ proofs=_proofs_from_json(row[8]),
559
+ audit_hash=row[9],
560
+ requested_at=float(row[10]),
561
+ completed_at=float(row[11]),
562
+ persisted=True,
563
+ )
564
+
565
+
566
+ def verify_receipt(
567
+ conn: object, erasure_id: str, *, profile_id: str | None = None,
568
+ ) -> bool:
569
+ """Verify the audit_hash of an erasure receipt.
570
+
571
+ Uses HMAC (v2) when the ``receipt_version`` column is present and
572
+ the row has version >= 2, otherwise falls back to unkeyed SHA-256 (v1).
573
+ """
574
+ predicate = "erasure_id = ?"
575
+ params: list[object] = [erasure_id]
576
+ if profile_id is not None:
577
+ predicate += " AND profile_id = ?"
578
+ params.append(profile_id)
579
+
580
+ db_version = _receipt_version_supported(conn)
581
+ row_version = _receipt_row_version(conn, erasure_id)
582
+
583
+ row = conn.execute(
584
+ "SELECT erasure_id, profile_id, subject_type, subject_id, requested_by, "
585
+ "fact_count, state, all_erased, owner_evidence_json, audit_hash, "
586
+ f"requested_at, completed_at FROM erasure_receipts WHERE {predicate}",
587
+ tuple(params),
588
+ ).fetchone()
589
+ if row is None:
590
+ return False
591
+
592
+ kwargs = dict(
593
+ erasure_id=row[0],
594
+ profile_id=row[1],
595
+ subject_type=row[2],
596
+ subject_id=row[3],
597
+ requested_by=row[4],
598
+ fact_count=int(row[5]),
599
+ state=row[6],
600
+ all_erased=bool(row[7]),
601
+ evidence_json=row[8],
602
+ requested_at=float(row[10]),
603
+ completed_at=float(row[11]),
604
+ )
605
+
606
+ if db_version >= _RECEIPT_V2:
607
+ # On M037-capable DBs NEVER accept the unkeyed v1 SHA path — a
608
+ # version-downgrade forgery rewrites receipt_version=1 with a valid
609
+ # SHA of mutated content. Any v1 row here is rejected; legitimate
610
+ # old rows should be re-sealed before use.
611
+ if row_version < _RECEIPT_V2:
612
+ return False
613
+ from superlocalmemory.core.transactions.manifest_key import (
614
+ derive_receipt_hmac_key,
615
+ verify_hmac,
616
+ )
617
+ canonical = _erasure_canonical(**kwargs, receipt_version=_RECEIPT_V2)
618
+ return verify_hmac(row[9], derive_receipt_hmac_key(), canonical)
619
+
620
+ # v1 path (M037 absent) — use constant-time comparison to prevent
621
+ # timing oracles on old SHA256 hex digests.
622
+ recomputed = compute_erasure_hash(**kwargs)
623
+ stored = row[9] or ""
624
+ return _hmac_mod.compare_digest(
625
+ recomputed.encode("utf-8"), stored.encode("utf-8")
626
+ )
627
+
628
+
629
+ # Tombstone write outcomes. WRITTEN/ABSENT are non-failures (ABSENT = the
630
+ # table does not exist on an older schema, or there were no fact ids);
631
+ # CONFLICT and ERROR must fail the erasure closed on a modern store.
632
+ TOMBSTONE_WRITTEN = "written"
633
+ TOMBSTONE_ABSENT = "absent"
634
+ TOMBSTONE_CONFLICT = "conflict"
635
+ TOMBSTONE_ERROR = "error"
636
+
637
+
638
+ def write_tombstones_status(
639
+ db: object,
640
+ profile_id: str,
641
+ fact_ids: tuple[str, ...],
642
+ erasure_id: str,
643
+ created_at: float,
644
+ memory_id: str | None = None,
645
+ ) -> str:
646
+ """Write projection tombstones and report a precise outcome code.
647
+
648
+ Returns one of TOMBSTONE_WRITTEN / TOMBSTONE_ABSENT / TOMBSTONE_CONFLICT /
649
+ TOMBSTONE_ERROR so callers can fail an erasure closed on a real failure
650
+ while still tolerating an older schema without the tombstone table.
651
+ """
652
+ if not fact_ids:
653
+ return TOMBSTONE_ABSENT
654
+ try:
655
+ with db.raw_connection() as conn:
656
+ if not _table_exists(conn, "projection_tombstones"):
657
+ return TOMBSTONE_ABSENT
658
+ for fact_id in fact_ids:
659
+ conn.execute(
660
+ "INSERT INTO projection_tombstones "
661
+ "(profile_id, fact_id, erasure_id, memory_id, created_at) "
662
+ "VALUES (?, ?, ?, ?, ?) "
663
+ "ON CONFLICT(profile_id, fact_id) DO UPDATE SET "
664
+ "memory_id = COALESCE(projection_tombstones.memory_id, excluded.memory_id)",
665
+ (profile_id, fact_id, erasure_id, memory_id, created_at),
666
+ )
667
+ stored = conn.execute(
668
+ "SELECT memory_id FROM projection_tombstones "
669
+ "WHERE profile_id = ? AND fact_id = ?",
670
+ (profile_id, fact_id),
671
+ ).fetchone()
672
+ if (
673
+ stored is not None
674
+ and memory_id is not None
675
+ and stored[0] is not None
676
+ and stored[0] != memory_id
677
+ ):
678
+ logger.error(
679
+ "tombstone provenance conflict for %s: stored=%r != passed=%r; "
680
+ "failing closed",
681
+ fact_id[:16], stored[0], memory_id,
682
+ )
683
+ try:
684
+ conn.rollback()
685
+ except Exception: # noqa: BLE001
686
+ pass
687
+ return TOMBSTONE_CONFLICT
688
+ conn.commit()
689
+ return TOMBSTONE_WRITTEN
690
+ except Exception as exc: # noqa: BLE001
691
+ logger.warning("erasure tombstone write skipped: %s", _err(exc))
692
+ return TOMBSTONE_ERROR
693
+
694
+
695
+ def write_tombstones(
696
+ db: object,
697
+ profile_id: str,
698
+ fact_ids: tuple[str, ...],
699
+ erasure_id: str,
700
+ created_at: float,
701
+ memory_id: str | None = None,
702
+ ) -> bool:
703
+ """Backward-compatible boolean wrapper: True only on a clean write."""
704
+ return (
705
+ write_tombstones_status(
706
+ db, profile_id, fact_ids, erasure_id, created_at, memory_id
707
+ )
708
+ == TOMBSTONE_WRITTEN
709
+ )
710
+
711
+
712
+ def is_tombstoned(conn: object, profile_id: str, fact_id: str) -> bool:
713
+ row = conn.execute(
714
+ "SELECT 1 FROM projection_tombstones WHERE profile_id = ? AND fact_id = ?",
715
+ (profile_id, fact_id),
716
+ ).fetchone()
717
+ return row is not None
718
+
719
+
720
+ def tombstone_memory_id(db: object, profile_id: str, fact_id: str) -> str | None:
721
+ try:
722
+ with db.raw_connection() as conn:
723
+ if not _table_exists(conn, "projection_tombstones"):
724
+ return None
725
+ row = conn.execute(
726
+ "SELECT memory_id FROM projection_tombstones "
727
+ "WHERE profile_id = ? AND fact_id = ?",
728
+ (profile_id, fact_id),
729
+ ).fetchone()
730
+ return row[0] if row and row[0] else None
731
+ except Exception: # noqa: BLE001
732
+ return None
733
+
734
+
735
+ def _proofs_from_json(payload: str) -> tuple[ErasureProofRecord, ...]:
736
+ try:
737
+ parsed = json.loads(payload)
738
+ except (TypeError, json.JSONDecodeError):
739
+ return ()
740
+ entries = parsed.get("proofs") if isinstance(parsed, dict) else None
741
+ if not isinstance(entries, list):
742
+ return ()
743
+ records: list[ErasureProofRecord] = []
744
+ for entry in entries:
745
+ if not isinstance(entry, dict):
746
+ continue
747
+ raw_residue = entry.get("residue")
748
+ residue = (
749
+ tuple(str(x) for x in raw_residue)
750
+ if isinstance(raw_residue, list) else ()
751
+ )
752
+ try:
753
+ records.append(ErasureProofRecord(
754
+ owner=str(entry["owner"]),
755
+ erased=bool(entry["erased"]),
756
+ checksum=str(entry["checksum"]),
757
+ residue=residue,
758
+ ))
759
+ except KeyError:
760
+ continue
761
+ return tuple(records)
762
+
763
+
764
+ def _table_exists(conn: object, name: str) -> bool:
765
+ return conn.execute(
766
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
767
+ (name,),
768
+ ).fetchone() is not None
769
+
770
+
771
+ def _receipt_version_supported(conn: object) -> int:
772
+ """Return the highest receipt version this DB supports."""
773
+ try:
774
+ cols = {row[1] for row in conn.execute("PRAGMA table_info(erasure_receipts)").fetchall()}
775
+ return _RECEIPT_V2 if "receipt_version" in cols else _RECEIPT_V1
776
+ except Exception: # noqa: BLE001
777
+ # Fail-closed: PRAGMA failure must not silently route verification through
778
+ # the unkeyed-SHA v1 path — same downgrade-forgery risk as manifest probe.
779
+ return _RECEIPT_V2
780
+
781
+
782
+ def _receipt_version_from_db(db: object) -> int:
783
+ """Return the highest receipt version supported by the DB wrapper."""
784
+ try:
785
+ with db.raw_connection() as conn:
786
+ return _receipt_version_supported(conn)
787
+ except Exception: # noqa: BLE001
788
+ return _RECEIPT_V1
789
+
790
+
791
+ def _receipt_row_version(conn: object, erasure_id: str) -> int:
792
+ """Read the receipt_version for an existing row; fall back to V1."""
793
+ try:
794
+ cols = {row[1] for row in conn.execute("PRAGMA table_info(erasure_receipts)").fetchall()}
795
+ if "receipt_version" not in cols:
796
+ return _RECEIPT_V1
797
+ row = conn.execute(
798
+ "SELECT receipt_version FROM erasure_receipts WHERE erasure_id = ?",
799
+ (erasure_id,),
800
+ ).fetchone()
801
+ return int(row[0]) if row and row[0] is not None else _RECEIPT_V1
802
+ except Exception: # noqa: BLE001
803
+ return _RECEIPT_V1
804
+
805
+
806
+ def _err(exc: BaseException) -> str:
807
+ return f"{type(exc).__name__}: {exc}"[:500]
808
+
809
+
810
+ __all__ = [
811
+ "ErasureProofRecord",
812
+ "ErasureReceipt",
813
+ "ErasureService",
814
+ "ErasureState",
815
+ "RemoveResult",
816
+ "MAX_ERASE_ATTEMPTS",
817
+ "VALID_SUBJECT_TYPES",
818
+ "compute_erasure_hash",
819
+ "compute_erasure_hmac",
820
+ "fetch_receipt",
821
+ "is_tombstoned",
822
+ "tombstone_memory_id",
823
+ "verify_receipt",
824
+ "write_tombstones",
825
+ ]