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
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  import logging
9
9
  import sqlite3
10
+ import uuid
10
11
  from pathlib import Path
11
12
  from typing import Any
12
13
 
@@ -76,6 +77,30 @@ def _invalidate_context_cache_for_fact(
76
77
  pass
77
78
 
78
79
 
80
+ def purge_profile_context_cache(engine: Any, profile_id: str) -> None:
81
+ """Best-effort full-profile cache purge after a truth lifecycle change.
82
+
83
+ Apply and rollback can flip which of two immutable facts is current. A
84
+ fact-ID substring delete is therefore insufficient: any cached prose for
85
+ the profile could contain the old current truth. Read-side admission
86
+ remains the authoritative backstop if this post-commit cleanup is busy.
87
+ """
88
+ try:
89
+ db_parent = Path(engine._db.db_path).parent
90
+ except AttributeError:
91
+ return
92
+ try:
93
+ from superlocalmemory.core.context_cache import purge_profile_from_cache_db
94
+
95
+ for cache_path in (
96
+ db_parent / "context-cache" / "active_brain_cache.db",
97
+ db_parent / "active_brain_cache.db",
98
+ ):
99
+ purge_profile_from_cache_db(cache_path, profile_id)
100
+ except Exception:
101
+ logger.warning("Profile context cache purge failed after correction transition")
102
+
103
+
79
104
  def _sync_vector_ann(
80
105
  retrieval: Any,
81
106
  fact_id: str,
@@ -173,6 +198,83 @@ def _converge_update_projections(
173
198
  logger.warning("Derived projection update sync failed for %s", fact_id[:16])
174
199
 
175
200
 
201
+ def _project_correction_successor(
202
+ engine: Any,
203
+ fact_id: str,
204
+ content: str,
205
+ profile_id: str,
206
+ embedding: list[float] | None,
207
+ ) -> None:
208
+ """Project a new successor without touching predecessor projections."""
209
+ retrieval = getattr(engine, "_retrieval_engine", None)
210
+ bm25 = getattr(retrieval, "_bm25", None) if retrieval else None
211
+ if bm25 is not None:
212
+ try:
213
+ bm25.add(fact_id, content, profile_id)
214
+ except Exception as exc:
215
+ logger.warning("BM25 successor projection failed for %s: %s", fact_id[:16], exc)
216
+ if retrieval is not None:
217
+ _sync_vector_ann(retrieval, fact_id, profile_id, embedding, operation="update")
218
+
219
+
220
+ def _correction_ledger_available(engine: Any) -> bool:
221
+ try:
222
+ rows = engine._db.execute(
223
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='correction_cases'"
224
+ )
225
+ return bool(rows)
226
+ except Exception:
227
+ return False
228
+
229
+
230
+ def _propose_correction_case(
231
+ engine: Any,
232
+ *,
233
+ profile_id: str,
234
+ scope: str,
235
+ predecessor_fact_id: str,
236
+ successor_fact_id: str,
237
+ trusted_actor_id: str,
238
+ idempotency_key: str,
239
+ ) -> dict[str, Any]:
240
+ """Record a review-required M042 proposal after successor persistence.
241
+
242
+ The ledger deliberately lives on a separate short transaction from the
243
+ canonical writer. A retry with the same deterministic identifiers is
244
+ idempotent; the predecessor remains untouched if the ledger is unavailable.
245
+ """
246
+ from superlocalmemory.storage.correction_cases import (
247
+ CorrectionActor,
248
+ CorrectionCaseStore,
249
+ )
250
+
251
+ actor = CorrectionActor(
252
+ actor_id=trusted_actor_id,
253
+ actor_kind="host_authenticated",
254
+ trust_tier="trusted",
255
+ )
256
+ case_id = uuid.uuid5(
257
+ uuid.NAMESPACE_URL,
258
+ f"slm-correction:{profile_id}:{predecessor_fact_id}:{successor_fact_id}",
259
+ ).hex
260
+ store = CorrectionCaseStore(
261
+ engine._db.db_path,
262
+ is_profile_active=lambda candidate: candidate == profile_id,
263
+ is_actor_trusted=lambda candidate: candidate == actor,
264
+ )
265
+ case = store.propose(
266
+ case_id=case_id,
267
+ profile_id=profile_id,
268
+ scope=scope,
269
+ predecessor_fact_id=predecessor_fact_id,
270
+ successor_fact_id=successor_fact_id,
271
+ reason_code="direct_content_correction",
272
+ actor=actor,
273
+ idempotency_key=idempotency_key,
274
+ )
275
+ return {"case_id": case.case_id, "status": case.status, "version": case.version}
276
+
277
+
176
278
  def _purge_delete_projections(
177
279
  engine: Any,
178
280
  fact_id: str,
@@ -498,7 +600,7 @@ def update_fact_authorized(
498
600
  canonical_runtime: Any | None = None,
499
601
  idempotency_key: str | None = None,
500
602
  ) -> dict[str, Any]:
501
- """Authorize a fact correction and converge every derived projection."""
603
+ """Create a review-required correction successor without rewriting history."""
502
604
  if not content or not content.strip():
503
605
  return {"ok": False, "error": "content cannot be empty"}
504
606
  content = content.strip()
@@ -517,42 +619,70 @@ def update_fact_authorized(
517
619
  )
518
620
  if not rows:
519
621
  return {"ok": False, "error": f"Memory {fact_id} not found"}
520
- old_content = dict(rows[0]).get("content", "")[:80]
521
- updates: dict[str, Any] = {"content": content}
622
+ source = engine._db.get_fact(fact_id)
623
+ if source is None:
624
+ return {"ok": False, "error": f"Memory {fact_id} not found"}
625
+ if source.content == content:
626
+ return {"ok": True, "fact_id": fact_id, "content": content, "unchanged": True}
627
+ if not _correction_ledger_available(engine):
628
+ return {"ok": False, "error": "review-gated correction ledger is unavailable"}
629
+
630
+ request_key = idempotency_key or uuid.uuid4().hex
631
+ successor_fact_id = uuid.uuid5(
632
+ uuid.NAMESPACE_URL,
633
+ f"slm-correction-successor:{profile_id}:{fact_id}:{request_key}",
634
+ ).hex[:16]
522
635
  embedding: list[float] | None = None
636
+ fisher_mean: list[float] | None = None
637
+ fisher_variance: list[float] | None = None
523
638
  if engine._embedder:
524
639
  try:
525
640
  embedding = engine._embedder.embed(content)
526
641
  if embedding:
527
- updates["embedding"] = embedding
528
642
  fisher_mean, fisher_variance = (
529
643
  engine._embedder.compute_fisher_params(embedding)
530
644
  )
531
- updates["fisher_mean"] = fisher_mean
532
- updates["fisher_variance"] = fisher_variance
533
645
  except Exception as exc:
534
- logger.warning("UPDATE embedding refresh failed: %s", exc)
535
- if canonical_runtime is not None:
536
- result = dict(canonical_runtime.update_fact(
537
- profile_id,
538
- fact_id,
539
- updates,
540
- idempotency_key=idempotency_key,
541
- ))
542
- if not result.get("ok"):
543
- return {"ok": False, "error": f"Memory {fact_id} not found"}
544
- else:
545
- engine._db.update_fact(fact_id, updates, profile_id=profile_id)
546
-
547
- # Converge every derived projection to the corrected content
548
- _converge_update_projections(engine, fact_id, content, profile_id, embedding)
646
+ logger.warning("Correction successor embedding refresh failed: %s", exc)
647
+ if canonical_runtime is None:
648
+ return {
649
+ "ok": False,
650
+ "retryable": True,
651
+ "error": "canonical correction writer is temporarily unavailable",
652
+ }
653
+ result = dict(canonical_runtime.create_correction_successor(
654
+ profile_id,
655
+ fact_id,
656
+ successor_fact_id,
657
+ content,
658
+ embedding=embedding,
659
+ fisher_mean=fisher_mean,
660
+ fisher_variance=fisher_variance,
661
+ trusted_actor_id=trusted_actor_id,
662
+ idempotency_key=request_key,
663
+ ))
664
+ if not result.get("ok"):
665
+ return {"ok": False, "error": f"Memory {fact_id} not found"}
666
+ successor_fact_id = str(result["successor_fact_id"])
549
667
 
550
668
  engine._hooks.run_post("update", context)
551
669
  logger.info(
552
- "UPDATE fact_id=%s actor=%s source_agent=%s old=%s new=%s",
553
- fact_id[:16], trusted_actor_id, source_agent_id, old_content, content[:80],
670
+ "CORRECTION_PROPOSED predecessor=%s successor=%s actor=%s source_agent=%s",
671
+ fact_id[:16], successor_fact_id[:16], trusted_actor_id, source_agent_id,
554
672
  )
555
- return {"ok": True, "fact_id": fact_id, "content": content}
673
+ return {
674
+ "ok": True,
675
+ "fact_id": successor_fact_id,
676
+ "content": content,
677
+ "predecessor_fact_id": fact_id,
678
+ "successor_fact_id": successor_fact_id,
679
+ "correction_case": {
680
+ "case_id": result["case_id"],
681
+ "status": result["status"],
682
+ "version": result["version"],
683
+ },
684
+ "review_required": True,
685
+ }
556
686
 
557
687
 
558
- __all__ = ["delete_fact_authorized", "update_fact_authorized"]
688
+ __all__ = ["delete_fact_authorized", "purge_profile_context_cache", "update_fact_authorized"]
@@ -327,20 +327,16 @@ class _ReadOnlyLearningView:
327
327
  def _resolve_ranking_mode(env: "dict[str, str] | os._Environ[str]") -> str:
328
328
  """Map the ``SLM_RANKING`` env var to a canonical mode.
329
329
 
330
- Legacy ``SLM_V2_PIPELINE_DISABLED=1`` and ``SLM_BANDIT_DISABLED=1``
331
- are honoured for one-release back-compat. Explicit ``SLM_RANKING``
332
- wins if both are set.
330
+ ``SLM_RANKING`` is an explicit operator policy. In 4.0.5 the absence of
331
+ that policy is deliberately ``off``: old learning signals must not begin
332
+ altering recall merely because a user upgrades. The legacy disable flags
333
+ remain harmless compatibility inputs, but cannot implicitly enable a
334
+ ranking mode.
333
335
  """
334
336
  raw = (env.get("SLM_RANKING", "") or "").strip().lower()
335
337
  if raw in _RANKING_MODES:
336
338
  return raw
337
- if (env.get("SLM_V2_PIPELINE_DISABLED", "0") or "0").strip() == "1":
338
- # v2 disabled → fall back to v1 adaptive only.
339
- return "v1"
340
- if (env.get("SLM_BANDIT_DISABLED", "0") or "0").strip() == "1":
341
- # Bandit disabled → v2 without ensemble.
342
- return "v2"
343
- return "v2-ensemble"
339
+ return "off"
344
340
 
345
341
 
346
342
  def apply_ranking(
@@ -238,18 +238,39 @@ def _handle_update_memory(
238
238
  content: str,
239
239
  source_agent_id: str = "system",
240
240
  ) -> dict:
241
- """Update a fact after capability-derived authorization."""
242
- engine = _get_engine()
243
- from superlocalmemory.core.engine_ingestion import local_trusted_actor_id
244
- from superlocalmemory.core.mutations import update_fact_authorized
245
-
246
- return update_fact_authorized(
247
- engine,
248
- fact_id,
249
- content,
250
- trusted_actor_id=local_trusted_actor_id("recall-worker"),
251
- source_agent_id=source_agent_id,
252
- )
241
+ """Update a fact after capability-derived authorization.
242
+
243
+ CORRECTIONS CANNOT BE PERFORMED FROM THIS WORKER, AND THAT IS BY DESIGN.
244
+
245
+ Since corrections became a review-gated lifecycle (M042) rather than an
246
+ in-place edit, update_fact_authorized() requires a canonical correction
247
+ writer. That writer is the daemon's single-writer mutation boundary
248
+ (CanonicalRememberRuntime, owned by unified_daemon and handed to the HTTP
249
+ route). A worker subprocess cannot own it: CanonicalRememberRuntime.ready
250
+ requires a live worker of its own, so constructing one here would nest
251
+ worker processes and hand a second writer the ownership context that is
252
+ supposed to be exclusive.
253
+
254
+ Previously this called through anyway, and mutations.py answered
255
+ "canonical correction writer is temporarily unavailable" with
256
+ retryable=True. Nothing about it was temporary — with the daemon down that
257
+ path can NEVER succeed, so a caller could retry forever. This is the
258
+ honest, non-retryable answer with the actual remedy: the daemon-backed
259
+ route (used automatically whenever the daemon is running) does support
260
+ corrections.
261
+ """
262
+ return {
263
+ "ok": False,
264
+ "retryable": False,
265
+ "error": (
266
+ "Corrections are review-gated and require the SuperLocalMemory "
267
+ "daemon, which owns the single correction writer. Start it with "
268
+ "`slm serve start` and retry — updates route through the daemon "
269
+ "automatically when it is running."
270
+ ),
271
+ "remedy": "slm serve start",
272
+ "fact_id": fact_id,
273
+ }
253
274
 
254
275
 
255
276
  def _handle_summarize(texts: list[str], mode: str) -> dict:
@@ -105,7 +105,7 @@ class DaemonAlreadyServing(RuntimeError):
105
105
  """
106
106
 
107
107
 
108
- class CanonicalMutationConflict(ValueError):
108
+ class CanonicalMutationConflict(WriteCoordinatorError, ValueError):
109
109
  """A mutation retry key was reused for different immutable input."""
110
110
 
111
111
 
@@ -337,6 +337,12 @@ class CanonicalRememberRuntime:
337
337
  self.coordinator.register_handler(CommandKind.ADMISSION, self._handle_admission)
338
338
  self.coordinator.register_handler(CommandKind.DELETE_FACT, self._handle_mutation)
339
339
  self.coordinator.register_handler(CommandKind.UPDATE_FACT, self._handle_mutation)
340
+ self.coordinator.register_handler(CommandKind.PROPOSE_CORRECTION, self._handle_mutation)
341
+ self.coordinator.register_handler(CommandKind.APPLY_CORRECTION, self._handle_mutation)
342
+ self.coordinator.register_handler(CommandKind.REJECT_CORRECTION, self._handle_mutation)
343
+ self.coordinator.register_handler(
344
+ CommandKind.ROLLBACK_CORRECTION, self._handle_mutation,
345
+ )
340
346
  self.coordinator.register_handler(CommandKind.ARCHIVE_FACT, self._handle_mutation)
341
347
  self.coordinator.register_handler(CommandKind.MERGE_FACT, self._handle_mutation)
342
348
  self.coordinator.register_handler(CommandKind.SET_FACT_SCOPE, self._handle_mutation)
@@ -484,6 +490,66 @@ class CanonicalRememberRuntime:
484
490
  idempotency_key=idempotency_key,
485
491
  )
486
492
 
493
+ def create_correction_successor(
494
+ self,
495
+ profile_id: str,
496
+ fact_id: str,
497
+ successor_fact_id: str,
498
+ content: str,
499
+ *,
500
+ embedding: list[float] | None = None,
501
+ fisher_mean: list[float] | None = None,
502
+ fisher_variance: list[float] | None = None,
503
+ trusted_actor_id: str = "canonical-runtime",
504
+ idempotency_key: str | None = None,
505
+ ) -> Mapping[str, Any]:
506
+ """Atomically create an immutable, review-required successor case."""
507
+ return self._submit_mutation(
508
+ CommandKind.PROPOSE_CORRECTION,
509
+ profile_id,
510
+ {
511
+ "fact_id": fact_id,
512
+ "successor_fact_id": successor_fact_id,
513
+ "content": content,
514
+ "embedding": _json_roundtrip({"value": embedding})["value"],
515
+ "fisher_mean": _json_roundtrip({"value": fisher_mean})["value"],
516
+ "fisher_variance": _json_roundtrip({"value": fisher_variance})["value"],
517
+ "trusted_actor_id": trusted_actor_id,
518
+ },
519
+ idempotency_key=idempotency_key,
520
+ )
521
+
522
+ def transition_correction(
523
+ self,
524
+ profile_id: str,
525
+ case_id: str,
526
+ *,
527
+ action: str,
528
+ expected_version: int,
529
+ actor_id: str,
530
+ event_valid_until: str | None = None,
531
+ idempotency_key: str | None = None,
532
+ ) -> Mapping[str, Any]:
533
+ """Apply, reject, or roll back one server-authorized correction case."""
534
+ command = {
535
+ "apply": CommandKind.APPLY_CORRECTION,
536
+ "reject": CommandKind.REJECT_CORRECTION,
537
+ "rollback": CommandKind.ROLLBACK_CORRECTION,
538
+ }.get(action)
539
+ if command is None:
540
+ raise ValueError("correction action must be apply, reject, or rollback")
541
+ return self._submit_mutation(
542
+ command,
543
+ profile_id,
544
+ {
545
+ "case_id": case_id,
546
+ "expected_version": expected_version,
547
+ "trusted_actor_id": actor_id,
548
+ "event_valid_until": event_valid_until,
549
+ },
550
+ idempotency_key=idempotency_key,
551
+ )
552
+
487
553
  def archive_fact(
488
554
  self, profile_id: str, fact_id: str, *, idempotency_key: str | None = None,
489
555
  ) -> Mapping[str, Any]:
@@ -569,6 +635,10 @@ class CanonicalRememberRuntime:
569
635
  )
570
636
  try:
571
637
  return dict(self.coordinator.submit(command, timeout=2.0).receipt)
638
+ except CanonicalMutationConflict:
639
+ # A deterministic lifecycle conflict is not a writer outage. Let
640
+ # HTTP/CLI/MCP report the actionable 409/validation result.
641
+ raise
572
642
  except CommandConflictError as exc:
573
643
  raise CanonicalMutationConflict(
574
644
  "idempotency key belongs to a different mutation request"
@@ -686,7 +756,9 @@ class CanonicalRememberRuntime:
686
756
  if profile_id != self._profile_id:
687
757
  raise ValueError("mutation command targets a different profile")
688
758
  with self._db._bind_coordinator_connection(conn, capability):
689
- receipt = _execute_mutation(self._db, command.kind, profile_id, payload)
759
+ receipt = _execute_mutation(
760
+ self._db, command.kind, profile_id, payload, connection=conn
761
+ )
690
762
  receipt["operation_id"] = f"mutation:{command.kind.value}:{command.command_id}"
691
763
  return WriteResult.from_receipt(command, receipt)
692
764
 
@@ -730,8 +802,18 @@ def _execute_mutation(
730
802
  kind: CommandKind,
731
803
  profile_id: str,
732
804
  payload: Mapping[str, Any],
805
+ *,
806
+ connection: Any,
733
807
  ) -> dict[str, Any]:
734
808
  """Dispatch the finite mutation set; no policy, hooks, models, or I/O."""
809
+ if kind is CommandKind.PROPOSE_CORRECTION:
810
+ return _propose_correction_successor(db, profile_id, payload, connection=connection)
811
+ if kind in {
812
+ CommandKind.APPLY_CORRECTION,
813
+ CommandKind.REJECT_CORRECTION,
814
+ CommandKind.ROLLBACK_CORRECTION,
815
+ }:
816
+ return _transition_correction(db, kind, profile_id, payload, connection=connection)
735
817
  fact_id = _payload_text(payload, "fact_id")
736
818
  if kind is CommandKind.DELETE_FACT:
737
819
  return _delete_fact(db, fact_id, profile_id)
@@ -750,6 +832,26 @@ def _delete_fact(db: DatabaseManager, fact_id: str, profile_id: str) -> dict[str
750
832
  row = _fact_row(db, fact_id, profile_id)
751
833
  if row is None:
752
834
  return {"ok": False, "operation_id": f"delete:{fact_id}", "fact_id": fact_id}
835
+ # M042 deliberately retains immutable predecessor/successor history.
836
+ # SQLite's FK would reject a direct delete, but exposing that as a generic
837
+ # writer outage turns a real lifecycle conflict into a misleading 503.
838
+ # A dedicated erasure workflow owns ledger removal; ordinary forget never
839
+ # deletes a fact that is part of immutable correction history.
840
+ try:
841
+ protected = db.execute(
842
+ "SELECT 1 FROM correction_cases "
843
+ "WHERE profile_id=? AND (predecessor_fact_id=? OR successor_fact_id=?) LIMIT 1",
844
+ (profile_id, fact_id, fact_id),
845
+ )
846
+ except Exception as exc:
847
+ if "no such table" in str(exc).lower():
848
+ protected = []
849
+ else:
850
+ raise
851
+ if protected:
852
+ raise CanonicalMutationConflict(
853
+ "fact is protected by correction history; resolve the correction before forgetting it"
854
+ )
753
855
  db.delete_fact(fact_id, profile_id=profile_id)
754
856
  return {
755
857
  "ok": True,
@@ -782,6 +884,173 @@ def _update_fact(
782
884
  }
783
885
 
784
886
 
887
+ def _propose_correction_successor(
888
+ db: DatabaseManager,
889
+ profile_id: str,
890
+ payload: Mapping[str, Any],
891
+ *,
892
+ connection: Any,
893
+ ) -> dict[str, Any]:
894
+ """Create successor and M042 proposal in one canonical writer transaction.
895
+
896
+ System time is represented by the new fact's ``created_at`` and temporal
897
+ knowledge anchor. Event-time fields are copied exactly from the
898
+ predecessor because an edit payload has no trustworthy event-time claim.
899
+ """
900
+ fact_id = _payload_text(payload, "fact_id")
901
+ source = _fact_row(db, fact_id, profile_id)
902
+ if source is None:
903
+ return {"ok": False, "operation_id": f"correction:{fact_id}", "fact_id": fact_id}
904
+ successor_id = _payload_text(payload, "successor_fact_id")
905
+ content = _payload_text(payload, "content").strip()
906
+ if not content:
907
+ raise ValueError("correction successor content cannot be empty")
908
+ if successor_id == fact_id:
909
+ raise ValueError("correction successor must differ from predecessor")
910
+
911
+ from superlocalmemory.storage.models import AtomicFact, FactType, MemoryLifecycle, SignalType
912
+
913
+ def _list_payload(key: str) -> list[float] | None:
914
+ value = payload.get(key)
915
+ if value is None:
916
+ return None
917
+ if not isinstance(value, (list, tuple)) or not all(
918
+ isinstance(v, (int, float)) for v in value
919
+ ):
920
+ raise ValueError(f"correction successor {key} must be numeric or null")
921
+ return [float(v) for v in value]
922
+
923
+ now = datetime.now(timezone.utc).isoformat()
924
+ successor = AtomicFact(
925
+ fact_id=successor_id,
926
+ memory_id=str(source.get("memory_id") or ""),
927
+ profile_id=profile_id,
928
+ scope=str(source.get("scope") or "personal"),
929
+ shared_with=json.loads(source["shared_with"]) if source.get("shared_with") else None,
930
+ content=content,
931
+ fact_type=FactType(str(source.get("fact_type") or "semantic")),
932
+ entities=json.loads(source["entities_json"]) if source.get("entities_json") else [],
933
+ canonical_entities=(
934
+ json.loads(source["canonical_entities_json"])
935
+ if source.get("canonical_entities_json") else []
936
+ ),
937
+ observation_date=source.get("observation_date"),
938
+ referenced_date=source.get("referenced_date"),
939
+ interval_start=source.get("interval_start"),
940
+ interval_end=source.get("interval_end"),
941
+ confidence=float(source.get("confidence") or 0.0),
942
+ importance=float(source.get("importance") or 0.0),
943
+ evidence_count=1,
944
+ access_count=0,
945
+ source_turn_ids=(
946
+ json.loads(source["source_turn_ids_json"])
947
+ if source.get("source_turn_ids_json") else []
948
+ ),
949
+ session_id=str(source.get("session_id") or ""),
950
+ embedding=_list_payload("embedding"),
951
+ fisher_mean=_list_payload("fisher_mean"),
952
+ fisher_variance=_list_payload("fisher_variance"),
953
+ lifecycle=MemoryLifecycle.ACTIVE,
954
+ langevin_position=None,
955
+ emotional_valence=float(source.get("emotional_valence") or 0.0),
956
+ emotional_arousal=float(source.get("emotional_arousal") or 0.0),
957
+ signal_type=SignalType(str(source.get("signal_type") or "factual")),
958
+ created_at=now,
959
+ )
960
+ persisted_id = db.insert_fact_immutable(successor)
961
+ from superlocalmemory.storage.correction_cases import (
962
+ CorrectionActor,
963
+ propose_on_connection,
964
+ )
965
+
966
+ trusted_actor_id = _payload_text(payload, "trusted_actor_id")
967
+ actor = CorrectionActor(
968
+ actor_id=trusted_actor_id,
969
+ actor_kind="host_authenticated",
970
+ trust_tier="trusted",
971
+ )
972
+ case_id = uuid.uuid5(
973
+ uuid.NAMESPACE_URL,
974
+ f"slm-correction:{profile_id}:{fact_id}:{persisted_id}",
975
+ ).hex
976
+ case = propose_on_connection(
977
+ connection,
978
+ case_id=case_id,
979
+ profile_id=profile_id,
980
+ scope=str(source.get("scope") or "personal"),
981
+ predecessor_fact_id=fact_id,
982
+ successor_fact_id=persisted_id,
983
+ reason_code="direct_content_correction",
984
+ actor=actor,
985
+ idempotency_key=_payload_text(payload, "idempotency_key"),
986
+ is_profile_active=lambda candidate: candidate == profile_id,
987
+ is_actor_trusted=lambda candidate: candidate == actor,
988
+ )
989
+ return {
990
+ "ok": True,
991
+ "operation_id": f"correction:{fact_id}:{persisted_id}",
992
+ "predecessor_fact_id": fact_id,
993
+ "successor_fact_id": persisted_id,
994
+ "case_id": case.case_id,
995
+ "status": case.status,
996
+ "version": case.version,
997
+ "created_at": now,
998
+ }
999
+
1000
+
1001
+ def _transition_correction(
1002
+ db: DatabaseManager,
1003
+ kind: CommandKind,
1004
+ profile_id: str,
1005
+ payload: Mapping[str, Any],
1006
+ *,
1007
+ connection: Any,
1008
+ ) -> dict[str, Any]:
1009
+ """Advance one correction case under the same receipt transaction."""
1010
+ from superlocalmemory.storage.correction_cases import (
1011
+ CorrectionActor,
1012
+ transition_on_connection,
1013
+ )
1014
+
1015
+ case_id = _payload_text(payload, "case_id")
1016
+ version = payload.get("expected_version")
1017
+ if not isinstance(version, int) or isinstance(version, bool) or version < 0:
1018
+ raise ValueError("correction expected_version must be a non-negative integer")
1019
+ actor = CorrectionActor(
1020
+ actor_id=_payload_text(payload, "trusted_actor_id"),
1021
+ actor_kind="host_authenticated",
1022
+ trust_tier="trusted",
1023
+ )
1024
+ transitions = {
1025
+ CommandKind.APPLY_CORRECTION: ("proposed", "applied", True),
1026
+ CommandKind.REJECT_CORRECTION: ("proposed", "rejected", False),
1027
+ CommandKind.ROLLBACK_CORRECTION: ("applied", "rolled_back", True),
1028
+ }
1029
+ from_status, to_status, mutate_temporal = transitions[kind]
1030
+ case = transition_on_connection(
1031
+ connection,
1032
+ case_id=case_id,
1033
+ expected_version=version,
1034
+ actor=actor,
1035
+ operation_id=_payload_text(payload, "idempotency_key"),
1036
+ from_status=from_status,
1037
+ to_status=to_status,
1038
+ mutate_temporal=mutate_temporal,
1039
+ event_valid_until=payload.get("event_valid_until"),
1040
+ is_profile_active=lambda candidate: candidate == profile_id,
1041
+ is_actor_trusted=lambda candidate: candidate == actor,
1042
+ )
1043
+ return {
1044
+ "ok": True,
1045
+ "operation_id": f"correction:{to_status}:{case.case_id}",
1046
+ "case_id": case.case_id,
1047
+ "predecessor_fact_id": case.predecessor_fact_id,
1048
+ "successor_fact_id": case.successor_fact_id,
1049
+ "status": case.status,
1050
+ "version": case.version,
1051
+ }
1052
+
1053
+
785
1054
  def _archive_fact(
786
1055
  db: DatabaseManager, fact_id: str, profile_id: str, payload: Mapping[str, Any],
787
1056
  ) -> dict[str, Any]: