superlocalmemory 4.0.4 → 4.0.6

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 (90) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +23 -14
  3. package/ide/configs/codex-mcp.toml +2 -2
  4. package/package.json +3 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.mcp.json +1 -0
  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 +3 -2
  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 +2 -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 +6 -5
  26. package/plugin-src/skills/slm-graph/SKILL.md +2 -1
  27. package/plugin-src/skills/slm-profile/SKILL.md +1 -0
  28. package/pyproject.toml +1 -1
  29. package/src/superlocalmemory/__init__.py +1 -1
  30. package/src/superlocalmemory/access/rbac.py +106 -0
  31. package/src/superlocalmemory/brain/__init__.py +5 -0
  32. package/src/superlocalmemory/brain/truth.py +418 -0
  33. package/src/superlocalmemory/cli/__main__.py +17 -0
  34. package/src/superlocalmemory/cli/commands.py +96 -28
  35. package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
  36. package/src/superlocalmemory/cli/gdpr_io.py +109 -0
  37. package/src/superlocalmemory/cli/main.py +88 -0
  38. package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
  39. package/src/superlocalmemory/code_graph/graph_store.py +180 -3
  40. package/src/superlocalmemory/code_graph/parser.py +280 -100
  41. package/src/superlocalmemory/compliance/gdpr.py +358 -0
  42. package/src/superlocalmemory/core/config.py +44 -1
  43. package/src/superlocalmemory/core/context_cache.py +58 -1
  44. package/src/superlocalmemory/core/engine_wiring.py +5 -1
  45. package/src/superlocalmemory/core/maintenance.py +43 -1
  46. package/src/superlocalmemory/core/mutations.py +155 -25
  47. package/src/superlocalmemory/core/recall_pipeline.py +6 -10
  48. package/src/superlocalmemory/core/recall_worker.py +33 -12
  49. package/src/superlocalmemory/core/remember_runtime.py +271 -2
  50. package/src/superlocalmemory/core/store_pipeline.py +100 -38
  51. package/src/superlocalmemory/encoding/consolidator.py +17 -47
  52. package/src/superlocalmemory/encoding/temporal_validator.py +14 -18
  53. package/src/superlocalmemory/hooks/user_prompt_hook.py +1 -1
  54. package/src/superlocalmemory/infra/backup.py +138 -0
  55. package/src/superlocalmemory/infra/backup_obligations.py +423 -0
  56. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +4 -3
  57. package/src/superlocalmemory/learning/engagement.py +165 -0
  58. package/src/superlocalmemory/mcp/profiles.py +19 -7
  59. package/src/superlocalmemory/mcp/server.py +4 -2
  60. package/src/superlocalmemory/mcp/tools_brain.py +54 -10
  61. package/src/superlocalmemory/mcp/tools_code_graph.py +31 -4
  62. package/src/superlocalmemory/mcp/tools_core.py +88 -3
  63. package/src/superlocalmemory/mcp/tools_v3.py +20 -6
  64. package/src/superlocalmemory/retrieval/engine.py +28 -10
  65. package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
  66. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +119 -19
  67. package/src/superlocalmemory/server/routes/brain.py +297 -14
  68. package/src/superlocalmemory/server/routes/learning.py +13 -25
  69. package/src/superlocalmemory/server/routes/memories.py +129 -3
  70. package/src/superlocalmemory/server/routes/v3_api.py +171 -60
  71. package/src/superlocalmemory/storage/_migration_internals.py +4 -0
  72. package/src/superlocalmemory/storage/_schema_version.py +2 -2
  73. package/src/superlocalmemory/storage/correction_cases.py +670 -0
  74. package/src/superlocalmemory/storage/database.py +230 -24
  75. package/src/superlocalmemory/storage/migration_runner.py +7 -0
  76. package/src/superlocalmemory/storage/migrations/M042_correction_case_ledger.py +245 -0
  77. package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
  78. package/src/superlocalmemory/storage/models.py +12 -4
  79. package/src/superlocalmemory/storage/write_coordinator.py +4 -0
  80. package/src/superlocalmemory/summaries/__init__.py +37 -0
  81. package/src/superlocalmemory/summaries/base.py +108 -0
  82. package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
  83. package/src/superlocalmemory/summaries/project_work_log.py +424 -0
  84. package/src/superlocalmemory/summaries/session_summary.py +307 -0
  85. package/src/superlocalmemory/ui/css/design-system.css +76 -1
  86. package/src/superlocalmemory/ui/index.html +28 -11
  87. package/src/superlocalmemory/ui/js/brain.js +43 -7
  88. package/src/superlocalmemory/ui/js/od-agents.js +49 -5
  89. package/src/superlocalmemory/ui/js/od-brain.js +280 -84
  90. package/src/superlocalmemory/ui/js/od-graph.js +147 -6
@@ -0,0 +1,779 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V4 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """``slm gdpr`` — GDPR subject-rights CLI for enterprise DPOs.
6
+
7
+ Subcommands
8
+ -----------
9
+ status Compliance posture: receipts, audit counts, known gaps.
10
+ Safe read-only. Never prints raw memory content.
11
+ export Art.15/20 subject access / data portability.
12
+ Requires --profile. Writes JSON to --output FILE or stdout.
13
+ erase Art.17 right to erasure — IRREVERSIBLE.
14
+ Requires --profile PROFILE AND --yes (both mandatory).
15
+ Default output with neither flag is a dry-run preview.
16
+ verify Check HMAC integrity of an erasure receipt.
17
+ Exit 0 = VERIFIED, 1 = TAMPERED, 2 = NOT_FOUND.
18
+
19
+ Every subcommand accepts --json for evidence-pipeline integration.
20
+ Exit codes: 0 success, 1 erasure/verify failure, 2 refusal/not-found.
21
+
22
+ Safety design (DPO-grade):
23
+ - erase is the only irreversible operation. It refuses to run unless BOTH
24
+ --profile PROFILE AND --yes are supplied. A dry-run is always printed first
25
+ when --dry-run is given (or when --yes is absent).
26
+ - --json never emits raw memory content in status or verify output.
27
+ - verify exits non-zero on tampered receipts so a pipeline can branch on it.
28
+ - status honestly reports known compliance gaps (backups, code_graph) rather
29
+ than claiming completeness it cannot support.
30
+
31
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import json as _json
37
+ import sys
38
+ from argparse import Namespace
39
+ from pathlib import Path
40
+
41
+ # I/O helpers and GDPR compliance constants extracted to gdpr_io.py to keep
42
+ # this file under the 800-line hard cap (coding-style.md).
43
+ from superlocalmemory.cli.gdpr_io import (
44
+ ART_COVERAGE as _ART_COVERAGE,
45
+ KNOWN_GAPS as _KNOWN_GAPS,
46
+ _audit_chain_path,
47
+ _data_root,
48
+ _db_path,
49
+ _die,
50
+ _json_envelope,
51
+ _print_json,
52
+ )
53
+
54
+
55
+ # ─────────────────────────────────────────────────────────────────────────────
56
+ # status
57
+
58
+
59
+ def _cmd_gdpr_status(args: Namespace) -> None:
60
+ """``slm gdpr status [--profile P] [--json]``."""
61
+ use_json = getattr(args, "json", False)
62
+ profile = getattr(args, "profile", None)
63
+
64
+ root = _data_root()
65
+ db_path = root / "memory.db"
66
+ db_exists = db_path.exists()
67
+
68
+ receipts: list[dict] = []
69
+ audit_count = 0
70
+ audit_recent: list[dict] = []
71
+ profiles_found: list[str] = []
72
+
73
+ if db_exists:
74
+ try:
75
+ from superlocalmemory.storage.memory_write import memory_read
76
+ with memory_read(db_path) as conn:
77
+ conn.row_factory = _json_row_factory(conn)
78
+ # Receipts — never include audit_hash (HMAC key material)
79
+ scope_clause = "WHERE profile_id = ?" if profile else ""
80
+ scope_params = (profile,) if profile else ()
81
+ rows = conn.execute(
82
+ f"SELECT erasure_id, profile_id, subject_type, subject_id, "
83
+ f"requested_by, fact_count, state, all_erased, requested_at, "
84
+ f"completed_at FROM erasure_receipts "
85
+ f"{scope_clause} ORDER BY completed_at DESC LIMIT 50",
86
+ scope_params,
87
+ ).fetchall()
88
+ for r in rows:
89
+ receipts.append(_safe_receipt_row(r))
90
+ except Exception:
91
+ pass # Table absent on a fresh install — not an error
92
+
93
+ try:
94
+ from superlocalmemory.storage.memory_write import memory_read
95
+ with memory_read(db_path) as conn:
96
+ # Profile list
97
+ profile_rows = conn.execute(
98
+ "SELECT profile_id FROM profiles"
99
+ ).fetchall()
100
+ profiles_found = [r[0] for r in profile_rows if r]
101
+ except Exception:
102
+ pass
103
+
104
+ if db_exists:
105
+ try:
106
+ from superlocalmemory.compliance.audit import AuditChain
107
+ chain = AuditChain(str(_audit_chain_path()))
108
+ scope_filter = {"profile_id": profile} if profile else {}
109
+ audit_recent = chain.query(limit=10, **scope_filter)
110
+ audit_count = len(chain.query(limit=100_000, **scope_filter))
111
+ except Exception:
112
+ pass
113
+
114
+ data = {
115
+ "data_root": str(root),
116
+ "db_exists": db_exists,
117
+ "active_profile": profile or "default",
118
+ "profiles": profiles_found,
119
+ "receipts": {
120
+ "count": len(receipts),
121
+ "entries": receipts,
122
+ },
123
+ "audit": {
124
+ "count": audit_count,
125
+ "recent_10": _sanitise_audit(audit_recent),
126
+ },
127
+ "coverage": _ART_COVERAGE,
128
+ "known_gaps": _KNOWN_GAPS,
129
+ "next_actions": [
130
+ "slm gdpr export --profile PROFILE --output export.json",
131
+ "slm gdpr erase --profile PROFILE --dry-run",
132
+ "slm gdpr verify --receipt-id RECEIPT_ID",
133
+ ],
134
+ }
135
+
136
+ if use_json:
137
+ _print_json(_json_envelope("gdpr-status", data=data))
138
+ else:
139
+ _print_human_status(data)
140
+
141
+
142
+ def _json_row_factory(conn):
143
+ """Return a row_factory that makes rows indexable by position."""
144
+ import sqlite3
145
+ return sqlite3.Row
146
+
147
+
148
+ def _safe_receipt_row(row) -> dict:
149
+ """Convert an erasure_receipts row to a safe-to-emit dict (no HMAC)."""
150
+ try:
151
+ keys = (
152
+ "erasure_id", "profile_id", "subject_type", "subject_id",
153
+ "requested_by", "fact_count", "state", "all_erased",
154
+ "requested_at", "completed_at",
155
+ )
156
+ if hasattr(row, "keys"):
157
+ return {k: row[k] for k in keys if k in row.keys()}
158
+ return dict(zip(keys, row))
159
+ except Exception:
160
+ return {}
161
+
162
+
163
+ def _sanitise_audit(events: list) -> list[dict]:
164
+ """Strip any raw content from audit events; keep only metadata fields."""
165
+ safe: list[dict] = []
166
+ _ALLOWED = {
167
+ "event_id", "operation", "agent_id", "profile_id",
168
+ "timestamp", "created_at",
169
+ }
170
+ for ev in events:
171
+ if isinstance(ev, dict):
172
+ safe.append({k: v for k, v in ev.items() if k in _ALLOWED})
173
+ else:
174
+ safe.append({})
175
+ return safe
176
+
177
+
178
+ def _print_human_status(data: dict) -> None:
179
+ root = data["data_root"]
180
+ db = "EXISTS" if data["db_exists"] else "NOT FOUND"
181
+ print(f"\nGDPR Compliance Status")
182
+ print(f" Data root : {root}")
183
+ print(f" Database : {db}")
184
+ print(f" Profiles : {', '.join(data['profiles']) or '(none)'}")
185
+ print(f" Receipts : {data['receipts']['count']}")
186
+ print(f" Audit events: {data['audit']['count']}")
187
+ print()
188
+ print("Coverage:")
189
+ for k, v in data["coverage"].items():
190
+ print(f" {k}: {v}")
191
+ print()
192
+ print("Known gaps (not yet covered):")
193
+ for gap in data["known_gaps"]:
194
+ print(f" [{gap['ref']}] {gap['summary']}")
195
+ print()
196
+ print("Next steps:")
197
+ for action in data["next_actions"]:
198
+ print(f" slm {action}" if not action.startswith("slm") else f" {action}")
199
+
200
+
201
+ # ─────────────────────────────────────────────────────────────────────────────
202
+ # export
203
+ # ─────────────────────────────────────────────────────────────────────────────
204
+
205
+ def _cmd_gdpr_export(args: Namespace) -> None:
206
+ """``slm gdpr export --profile P [--output FILE] [--json]``."""
207
+ use_json = getattr(args, "json", False)
208
+ profile = getattr(args, "profile", None)
209
+ output_file = getattr(args, "output", None)
210
+
211
+ if not profile:
212
+ if use_json:
213
+ _print_json(_json_envelope(
214
+ "gdpr-export",
215
+ error={
216
+ "code": "MISSING_PROFILE",
217
+ "message": "Art.15 export requires --profile PROFILE_ID.",
218
+ "hint": "Run `slm gdpr status` to list known profiles.",
219
+ },
220
+ ))
221
+ else:
222
+ _die("Art.15 export requires --profile PROFILE_ID.\n"
223
+ " Run `slm gdpr status` to list known profiles.")
224
+ sys.exit(1)
225
+
226
+ root = _data_root()
227
+ db_path = root / "memory.db"
228
+
229
+ if not db_path.exists():
230
+ err = {
231
+ "code": "NO_DATABASE",
232
+ "message": "No SuperLocalMemory database found at the data root.",
233
+ "hint": (
234
+ f"Data root: {root}. "
235
+ "Start the daemon (`slm serve`) to create a database, "
236
+ "or set SLM_DATA_DIR to the correct path."
237
+ ),
238
+ }
239
+ if use_json:
240
+ _print_json(_json_envelope("gdpr-export", error=err))
241
+ else:
242
+ _die(f"{err['message']}\n {err['hint']}")
243
+ sys.exit(2)
244
+
245
+ try:
246
+ from superlocalmemory.storage.database import DatabaseManager
247
+ from superlocalmemory.compliance.gdpr import GDPRCompliance
248
+
249
+ # Do NOT call db.initialize() — GDPRCompliance drives all DDL via
250
+ # _profile_scoped_tables() / sqlite_master and works with the existing
251
+ # schema. Pattern mirrors commands.py:3233.
252
+ db = DatabaseManager(db_path)
253
+ gdpr = GDPRCompliance(db, data_root=root)
254
+ export_data = gdpr.export_profile_data(profile)
255
+ except Exception as exc:
256
+ if use_json:
257
+ _print_json(_json_envelope(
258
+ "gdpr-export",
259
+ error={"code": "EXPORT_ERROR", "message": str(exc)},
260
+ ))
261
+ else:
262
+ _die(f"Export failed: {exc}\n Run `slm gdpr status` to check DB state.")
263
+ sys.exit(1)
264
+
265
+ total = export_data.get("total_items", 0)
266
+
267
+ if output_file:
268
+ try:
269
+ out_path = Path(output_file)
270
+ out_path.write_text(
271
+ _json.dumps(export_data, indent=2, default=str), encoding="utf-8"
272
+ )
273
+ except OSError as exc:
274
+ if use_json:
275
+ _print_json(_json_envelope(
276
+ "gdpr-export",
277
+ error={"code": "WRITE_ERROR", "message": str(exc)},
278
+ ))
279
+ else:
280
+ _die(f"Failed to write export to {output_file}: {exc}")
281
+ sys.exit(1)
282
+
283
+ confirmation = {
284
+ "profile": profile,
285
+ "total_items": total,
286
+ "output_file": str(out_path.resolve()),
287
+ "exported_at": export_data.get("exported_at"),
288
+ "note": "Output file contains personal data. Handle as confidential.",
289
+ }
290
+ if use_json:
291
+ _print_json(_json_envelope("gdpr-export", data=confirmation))
292
+ else:
293
+ print(f"Art.15 export complete — {total} items written to {out_path.resolve()}")
294
+ print(" Handle as confidential personal data.")
295
+ else:
296
+ # No output file: emit the full export to stdout
297
+ if use_json:
298
+ # Wrap in envelope but the data payload IS the export
299
+ _print_json(_json_envelope("gdpr-export", data=export_data))
300
+ else:
301
+ print(_json.dumps(export_data, indent=2, default=str))
302
+
303
+
304
+ # ─────────────────────────────────────────────────────────────────────────────
305
+ # erase
306
+ # ─────────────────────────────────────────────────────────────────────────────
307
+
308
+ def _cmd_gdpr_erase(args: Namespace) -> None:
309
+ """``slm gdpr erase --profile P [--yes] [--dry-run] [--json]``."""
310
+ use_json = getattr(args, "json", False)
311
+ profile = getattr(args, "profile", None)
312
+ confirmed = getattr(args, "yes", False)
313
+ dry_run = getattr(args, "dry_run", False)
314
+
315
+ # ── SAFETY GATE 1: profile must be named explicitly ──────────────────────
316
+ if not profile:
317
+ msg = (
318
+ "Art.17 erasure requires --profile PROFILE_ID.\n"
319
+ " This operation is IRREVERSIBLE. Name the profile explicitly.\n"
320
+ " Run `slm gdpr status` to list known profiles.\n"
321
+ " Run `slm gdpr erase --profile PROFILE --dry-run` to preview."
322
+ )
323
+ if use_json:
324
+ _print_json(_json_envelope(
325
+ "gdpr-erase",
326
+ error={
327
+ "code": "MISSING_PROFILE",
328
+ "message": (
329
+ "Art.17 erasure requires --profile PROFILE_ID. "
330
+ "This operation is IRREVERSIBLE. "
331
+ "Run with --dry-run first."
332
+ ),
333
+ "hint": "slm gdpr status → list known profiles",
334
+ },
335
+ ))
336
+ else:
337
+ print(msg, file=sys.stderr)
338
+ sys.exit(2)
339
+
340
+ # ── SAFETY GATE 2: "default" profile cannot be erased ───────────────────
341
+ if profile == "default":
342
+ msg = (
343
+ "Cannot erase the 'default' profile via GDPR Art.17. "
344
+ "The default profile is a system profile. "
345
+ "Use a named profile: slm profile list"
346
+ )
347
+ if use_json:
348
+ _print_json(_json_envelope(
349
+ "gdpr-erase",
350
+ error={"code": "DEFAULT_PROFILE", "message": msg},
351
+ ))
352
+ else:
353
+ _die(msg, code=2)
354
+ sys.exit(2)
355
+
356
+ root = _data_root()
357
+ db_path = root / "memory.db"
358
+
359
+ if not db_path.exists():
360
+ err = {
361
+ "code": "NO_DATABASE",
362
+ "message": "No SuperLocalMemory database found at the data root.",
363
+ "hint": (
364
+ f"Data root: {root}. "
365
+ "Start the daemon (`slm serve`) to create a database."
366
+ ),
367
+ }
368
+ if use_json:
369
+ _print_json(_json_envelope("gdpr-erase", error=err))
370
+ else:
371
+ _die(f"{err['message']}\n {err['hint']}")
372
+ sys.exit(2)
373
+
374
+ # ── DRY-RUN: show exactly what would be erased ───────────────────────────
375
+ if dry_run or not confirmed:
376
+ _show_erase_preview(profile, root, db_path, use_json)
377
+ if not confirmed:
378
+ # Explicit --dry-run with no --yes → preview succeeded (exit 0).
379
+ # Implicit dry-run (neither --dry-run nor --yes) → refused (exit 2).
380
+ # A pipeline that doesn't supply --yes must see a non-zero exit.
381
+ if dry_run:
382
+ sys.exit(0)
383
+ if use_json:
384
+ _print_json(_json_envelope(
385
+ "gdpr-erase",
386
+ error={
387
+ "code": "CONFIRMATION_REQUIRED",
388
+ "message": (
389
+ "Erasure aborted — --yes not supplied. "
390
+ "This is a dry-run preview. "
391
+ "Re-run with --yes to confirm (IRREVERSIBLE)."
392
+ ),
393
+ "hint": f"slm gdpr erase --profile {profile} --yes",
394
+ },
395
+ ))
396
+ else:
397
+ print(
398
+ "\nErasure aborted — no --yes flag supplied.\n"
399
+ " Re-run with --yes to confirm (IRREVERSIBLE):\n"
400
+ f" slm gdpr erase --profile {profile} --yes",
401
+ file=sys.stderr,
402
+ )
403
+ sys.exit(2)
404
+ return # dry-run with --dry-run flag: preview only, exit 0
405
+
406
+ # ── LIVE ERASURE ─────────────────────────────────────────────────────────
407
+ try:
408
+ from superlocalmemory.storage.database import DatabaseManager
409
+ from superlocalmemory.compliance.gdpr import GDPRCompliance
410
+
411
+ db = DatabaseManager(db_path)
412
+ gdpr = GDPRCompliance(db, data_root=root)
413
+ counts = gdpr.forget_profile(profile)
414
+ except Exception as exc:
415
+ if use_json:
416
+ _print_json(_json_envelope(
417
+ "gdpr-erase",
418
+ error={
419
+ "code": "ERASE_ERROR",
420
+ "message": str(exc),
421
+ "hint": "The erasure may be incomplete. Check the audit_chain.db receipt.",
422
+ },
423
+ ))
424
+ else:
425
+ print(f"error: Erasure failed: {exc}", file=sys.stderr)
426
+ print(" The erasure may be incomplete. Check the audit_chain.db receipt.", file=sys.stderr)
427
+ sys.exit(1)
428
+
429
+ complete = counts.get("erasure_complete", 0)
430
+ result_data = {
431
+ "profile": profile,
432
+ "erasure_complete": bool(complete),
433
+ "counts": counts,
434
+ "note": (
435
+ "Erasure recorded in audit_chain.db. "
436
+ "Backups/ remain as an outstanding obligation (C1). "
437
+ "Run `slm gdpr verify --profile PROFILE` to confirm receipts."
438
+ ),
439
+ }
440
+
441
+ if use_json:
442
+ _print_json(_json_envelope("gdpr-erase", data=result_data))
443
+ else:
444
+ status_str = "COMPLETE" if complete else "INCOMPLETE (check counts)"
445
+ print(f"Art.17 erasure for profile '{profile}': {status_str}")
446
+ for k, v in counts.items():
447
+ print(f" {k}: {v}")
448
+ print()
449
+ print(" Backups/ contain outstanding obligation — see C1 gap.")
450
+ print(" Verify receipts: slm gdpr verify --profile", profile)
451
+
452
+ if not complete:
453
+ sys.exit(1)
454
+
455
+
456
+ def _show_erase_preview(
457
+ profile: str, root: Path, db_path: Path, use_json: bool
458
+ ) -> None:
459
+ """Show what would be erased without making any changes."""
460
+ tables: list[str] = []
461
+ row_counts: dict[str, int] = {}
462
+
463
+ if db_path.exists():
464
+ try:
465
+ from superlocalmemory.storage.memory_write import memory_read
466
+ with memory_read(db_path) as conn:
467
+ # Discover profile-scoped tables (mirroring GDPRCompliance logic)
468
+ _NON_MEMORY_SCOPED = {"profiles", "erasure_receipts"}
469
+ tbl_rows = conn.execute(
470
+ "SELECT name FROM sqlite_master WHERE type='table'"
471
+ ).fetchall()
472
+ for (tname,) in tbl_rows:
473
+ if tname.startswith("sqlite_") or tname in _NON_MEMORY_SCOPED:
474
+ continue
475
+ try:
476
+ cols = {
477
+ r[1] for r in conn.execute(
478
+ f"PRAGMA table_info({tname})"
479
+ ).fetchall()
480
+ }
481
+ if "profile_id" not in cols:
482
+ continue
483
+ cnt = conn.execute(
484
+ f"SELECT COUNT(*) FROM {tname} WHERE profile_id = ?",
485
+ (profile,),
486
+ ).fetchone()[0]
487
+ tables.append(tname)
488
+ row_counts[tname] = cnt
489
+ except Exception:
490
+ continue
491
+ except Exception:
492
+ pass
493
+
494
+ preview = {
495
+ "dry_run": True,
496
+ "profile": profile,
497
+ "would_erase": row_counts,
498
+ "tables_affected": len(tables),
499
+ "total_rows": sum(row_counts.values()),
500
+ "also_purged": [
501
+ "learning.db (sidecar)",
502
+ "active_brain_cache.db",
503
+ "vector store entries",
504
+ ],
505
+ "survives": [
506
+ "erasure_receipts (tamper-evident audit chain)",
507
+ "profiles record (for GDPR accountability)",
508
+ "backups/ (outstanding obligation — C1)",
509
+ ],
510
+ "warning": (
511
+ "This operation is IRREVERSIBLE. "
512
+ "Re-run with --yes to confirm."
513
+ ),
514
+ }
515
+
516
+ if use_json:
517
+ _print_json(_json_envelope("gdpr-erase-dryrun", data=preview))
518
+ else:
519
+ print(f"\nGDPR Art.17 dry-run for profile '{profile}':")
520
+ print(f" Tables to erase : {preview['tables_affected']}")
521
+ print(f" Total rows : {preview['total_rows']}")
522
+ for t, c in row_counts.items():
523
+ print(f" {t}: {c} rows")
524
+ print(" Also purged: " + ", ".join(preview["also_purged"]))
525
+ print(" Survives : " + ", ".join(preview["survives"]))
526
+ print()
527
+ print(f" WARNING: {preview['warning']}")
528
+
529
+
530
+ # ─────────────────────────────────────────────────────────────────────────────
531
+ # verify
532
+ # ─────────────────────────────────────────────────────────────────────────────
533
+
534
+ def _cmd_gdpr_verify(args: Namespace) -> None:
535
+ """``slm gdpr verify --receipt-id ID [--profile P] [--json]``.
536
+
537
+ Exit codes:
538
+ 0 — receipt found and HMAC is intact (VERIFIED)
539
+ 1 — receipt found but HMAC fails (TAMPERED — integrity violation)
540
+ 2 — receipt not found (NOT_FOUND)
541
+ """
542
+ use_json = getattr(args, "json", False)
543
+ receipt_id = getattr(args, "receipt_id", None)
544
+ profile = getattr(args, "profile", None)
545
+
546
+ # Allow listing all receipts for a profile
547
+ list_mode = not receipt_id and profile
548
+
549
+ root = _data_root()
550
+ db_path = root / "memory.db"
551
+
552
+ if not db_path.exists():
553
+ if use_json:
554
+ _print_json(_json_envelope(
555
+ "gdpr-verify",
556
+ error={
557
+ "code": "NOT_FOUND",
558
+ "message": "No database found at the data root.",
559
+ "hint": f"Data root: {root}",
560
+ },
561
+ ))
562
+ else:
563
+ _die(f"No database at {db_path}. Run `slm gdpr status` first.", code=2)
564
+ sys.exit(2)
565
+
566
+ if list_mode:
567
+ _list_receipts(profile, db_path, use_json)
568
+ return
569
+
570
+ if not receipt_id:
571
+ if use_json:
572
+ _print_json(_json_envelope(
573
+ "gdpr-verify",
574
+ error={
575
+ "code": "MISSING_RECEIPT_ID",
576
+ "message": "Provide --receipt-id ID to verify a specific receipt.",
577
+ "hint": "slm gdpr verify --profile PROFILE → list receipts",
578
+ },
579
+ ))
580
+ else:
581
+ _die(
582
+ "Provide --receipt-id ID to verify, or --profile PROFILE to list.\n"
583
+ " Example: slm gdpr verify --receipt-id <id>",
584
+ code=2,
585
+ )
586
+ sys.exit(2)
587
+
588
+ # Check if receipt exists first (to distinguish NOT_FOUND from TAMPERED)
589
+ receipt_meta: dict = {}
590
+ exists = False
591
+ try:
592
+ from superlocalmemory.storage.memory_write import memory_read
593
+ with memory_read(db_path) as conn:
594
+ params: tuple = (receipt_id,)
595
+ extra = ""
596
+ if profile:
597
+ extra = " AND profile_id = ?"
598
+ params = (receipt_id, profile)
599
+ row = conn.execute(
600
+ "SELECT erasure_id, profile_id, subject_type, subject_id, "
601
+ "fact_count, state, all_erased, requested_at, completed_at "
602
+ f"FROM erasure_receipts WHERE erasure_id = ?{extra}",
603
+ params,
604
+ ).fetchone()
605
+ if row:
606
+ exists = True
607
+ receipt_meta = {
608
+ "erasure_id": row[0],
609
+ "profile_id": row[1],
610
+ "subject_type": row[2],
611
+ "subject_id": row[3],
612
+ "fact_count": row[4],
613
+ "state": row[5],
614
+ "all_erased": bool(row[6]),
615
+ "requested_at": row[7],
616
+ "completed_at": row[8],
617
+ }
618
+ except Exception as exc:
619
+ if use_json:
620
+ _print_json(_json_envelope(
621
+ "gdpr-verify",
622
+ error={"code": "DB_ERROR", "message": str(exc)},
623
+ ))
624
+ else:
625
+ _die(f"Could not read database: {exc}", code=1)
626
+ sys.exit(1)
627
+
628
+ if not exists:
629
+ result = {
630
+ "erasure_id": receipt_id,
631
+ "status": "NOT_FOUND",
632
+ "verified": False,
633
+ "message": "Receipt ID not found in erasure_receipts.",
634
+ }
635
+ if use_json:
636
+ _print_json(_json_envelope("gdpr-verify", data=result))
637
+ else:
638
+ print(f"NOT_FOUND: Receipt '{receipt_id}' does not exist.")
639
+ sys.exit(2)
640
+
641
+ # Run HMAC verification
642
+ hmac_ok = False
643
+ verify_error: str | None = None
644
+ try:
645
+ from superlocalmemory.storage.memory_write import memory_read
646
+ from superlocalmemory.core.transactions.erasure import verify_receipt
647
+
648
+ with memory_read(db_path) as conn:
649
+ hmac_ok = verify_receipt(
650
+ conn, receipt_id, profile_id=profile
651
+ )
652
+ except Exception as exc:
653
+ verify_error = str(exc)
654
+
655
+ if verify_error:
656
+ result = {
657
+ "erasure_id": receipt_id,
658
+ "status": "VERIFY_ERROR",
659
+ "verified": False,
660
+ "error": verify_error,
661
+ "receipt": receipt_meta,
662
+ }
663
+ if use_json:
664
+ _print_json(_json_envelope("gdpr-verify", data=result))
665
+ else:
666
+ print(f"VERIFY_ERROR: {verify_error}", file=sys.stderr)
667
+ sys.exit(1)
668
+
669
+ status_str = "VERIFIED" if hmac_ok else "TAMPERED"
670
+ result = {
671
+ "erasure_id": receipt_id,
672
+ "status": status_str,
673
+ "verified": hmac_ok,
674
+ "receipt": receipt_meta,
675
+ "message": (
676
+ "HMAC chain is intact — deletion can be proven."
677
+ if hmac_ok else
678
+ "HMAC mismatch — receipt may have been tampered with. "
679
+ "Do NOT rely on this receipt as Art.17 evidence."
680
+ ),
681
+ }
682
+
683
+ if use_json:
684
+ _print_json(_json_envelope("gdpr-verify", data=result))
685
+ else:
686
+ print(f"{status_str}: receipt '{receipt_id}'")
687
+ for k, v in receipt_meta.items():
688
+ print(f" {k}: {v}")
689
+ print(f" HMAC: {'OK' if hmac_ok else 'MISMATCH'}")
690
+
691
+ sys.exit(0 if hmac_ok else 1)
692
+
693
+
694
+ def _list_receipts(profile: str, db_path: Path, use_json: bool) -> None:
695
+ """List all erasure receipts for a profile."""
696
+ receipts: list[dict] = []
697
+ try:
698
+ from superlocalmemory.storage.memory_write import memory_read
699
+ with memory_read(db_path) as conn:
700
+ rows = conn.execute(
701
+ "SELECT erasure_id, profile_id, subject_type, subject_id, "
702
+ "fact_count, state, all_erased, requested_at, completed_at "
703
+ "FROM erasure_receipts WHERE profile_id = ? "
704
+ "ORDER BY completed_at DESC",
705
+ (profile,),
706
+ ).fetchall()
707
+ for r in rows:
708
+ receipts.append({
709
+ "erasure_id": r[0],
710
+ "profile_id": r[1],
711
+ "subject_type": r[2],
712
+ "subject_id": r[3],
713
+ "fact_count": r[4],
714
+ "state": r[5],
715
+ "all_erased": bool(r[6]),
716
+ "requested_at": r[7],
717
+ "completed_at": r[8],
718
+ })
719
+ except Exception as exc:
720
+ if use_json:
721
+ _print_json(_json_envelope(
722
+ "gdpr-verify",
723
+ error={"code": "DB_ERROR", "message": str(exc)},
724
+ ))
725
+ else:
726
+ _die(f"Could not list receipts: {exc}")
727
+ sys.exit(1)
728
+
729
+ result = {
730
+ "profile": profile,
731
+ "count": len(receipts),
732
+ "receipts": receipts,
733
+ "hint": "Use --receipt-id ID to verify a specific receipt's HMAC.",
734
+ }
735
+
736
+ if use_json:
737
+ _print_json(_json_envelope("gdpr-verify", data=result))
738
+ else:
739
+ print(f"Erasure receipts for profile '{profile}': {len(receipts)}")
740
+ for r in receipts:
741
+ print(
742
+ f" [{r['erasure_id'][:16]}...] "
743
+ f"state={r['state']} "
744
+ f"facts={r['fact_count']} "
745
+ f"complete={r['all_erased']}"
746
+ )
747
+ if receipts:
748
+ print("\nVerify a receipt: slm gdpr verify --receipt-id <id>")
749
+
750
+
751
+ # ─────────────────────────────────────────────────────────────────────────────
752
+ # Public entry point
753
+ # ─────────────────────────────────────────────────────────────────────────────
754
+
755
+ def cmd_gdpr(args: Namespace) -> None:
756
+ """Dispatch ``slm gdpr`` subcommands."""
757
+ sub = getattr(args, "gdpr_command", None)
758
+ handlers = {
759
+ "status": _cmd_gdpr_status,
760
+ "export": _cmd_gdpr_export,
761
+ "erase": _cmd_gdpr_erase,
762
+ "verify": _cmd_gdpr_verify,
763
+ }
764
+ handler = handlers.get(sub)
765
+ if handler:
766
+ handler(args)
767
+ else:
768
+ print(
769
+ "Usage: slm gdpr <status|export|erase|verify> [options]\n"
770
+ "\n"
771
+ " slm gdpr status [--profile P] [--json]\n"
772
+ " slm gdpr export --profile P [--output FILE] [--json]\n"
773
+ " slm gdpr erase --profile P --yes [--dry-run] [--json]\n"
774
+ " slm gdpr verify --receipt-id ID [--profile P] [--json]\n"
775
+ "\n"
776
+ "All subcommands accept --json for evidence-pipeline integration.\n"
777
+ "Exit codes: 0=success, 1=failure/tampered, 2=refused/not-found.\n"
778
+ )
779
+ sys.exit(1)