superlocalmemory 4.0.4 → 4.0.5

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 (60) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +18 -13
  3. package/ide/configs/codex-mcp.toml +2 -2
  4. package/package.json +1 -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/brain/__init__.py +5 -0
  31. package/src/superlocalmemory/brain/truth.py +348 -0
  32. package/src/superlocalmemory/cli/commands.py +82 -25
  33. package/src/superlocalmemory/cli/main.py +12 -0
  34. package/src/superlocalmemory/core/context_cache.py +58 -1
  35. package/src/superlocalmemory/core/mutations.py +155 -25
  36. package/src/superlocalmemory/core/recall_pipeline.py +6 -10
  37. package/src/superlocalmemory/core/remember_runtime.py +271 -2
  38. package/src/superlocalmemory/core/store_pipeline.py +100 -38
  39. package/src/superlocalmemory/encoding/consolidator.py +17 -47
  40. package/src/superlocalmemory/encoding/temporal_validator.py +14 -18
  41. package/src/superlocalmemory/hooks/user_prompt_hook.py +1 -1
  42. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +4 -3
  43. package/src/superlocalmemory/mcp/profiles.py +19 -7
  44. package/src/superlocalmemory/mcp/server.py +4 -2
  45. package/src/superlocalmemory/mcp/tools_brain.py +54 -10
  46. package/src/superlocalmemory/mcp/tools_core.py +88 -3
  47. package/src/superlocalmemory/retrieval/engine.py +7 -10
  48. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +119 -19
  49. package/src/superlocalmemory/server/routes/brain.py +15 -0
  50. package/src/superlocalmemory/server/routes/memories.py +129 -3
  51. package/src/superlocalmemory/storage/_migration_internals.py +4 -0
  52. package/src/superlocalmemory/storage/_schema_version.py +2 -2
  53. package/src/superlocalmemory/storage/correction_cases.py +670 -0
  54. package/src/superlocalmemory/storage/database.py +194 -24
  55. package/src/superlocalmemory/storage/migration_runner.py +7 -0
  56. package/src/superlocalmemory/storage/migrations/M042_correction_case_ledger.py +245 -0
  57. package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
  58. package/src/superlocalmemory/storage/write_coordinator.py +4 -0
  59. package/src/superlocalmemory/ui/js/brain.js +43 -7
  60. package/src/superlocalmemory/ui/js/od-brain.js +44 -28
@@ -928,8 +928,11 @@ def register_core_tools(server, get_engine: Callable) -> None:
928
928
  )
929
929
  if isinstance(result, dict) and result.get("success"):
930
930
  return {
931
- "success": True, "fact_id": fact_id,
932
- "content": content.strip(),
931
+ "success": True,
932
+ "predecessor_fact_id": result.get("predecessor_fact_id", fact_id),
933
+ "successor_fact_id": result.get("successor_fact_id"),
934
+ "correction_case": result.get("correction_case"),
935
+ "review_required": bool(result.get("review_required", False)),
933
936
  }
934
937
  return {
935
938
  "success": False,
@@ -947,12 +950,94 @@ def register_core_tools(server, get_engine: Callable) -> None:
947
950
  })
948
951
  if result.get("ok"):
949
952
  logger.info("Memory updated: %s by agent: %s", fact_id[:16], agent_id)
950
- return {"success": True, "fact_id": fact_id, "content": content.strip()}
953
+ return {
954
+ "success": True,
955
+ "predecessor_fact_id": result.get("predecessor_fact_id", fact_id),
956
+ "successor_fact_id": result.get("successor_fact_id"),
957
+ "correction_case": result.get("correction_case"),
958
+ "review_required": bool(result.get("review_required", False)),
959
+ }
951
960
  return {"success": False, "error": result.get("error", "Update failed")}
952
961
  except Exception as exc:
953
962
  logger.exception("update_memory failed")
954
963
  return {"success": False, "error": str(exc)}
955
964
 
965
+ @server.tool(annotations=ToolAnnotations(idempotentHint=True))
966
+ @admits(OperationKind.CORRECT)
967
+ async def review_correction(
968
+ case_id: str,
969
+ action: str,
970
+ expected_version: int,
971
+ event_valid_until: str | None = None,
972
+ ) -> dict:
973
+ """Apply, reject, or roll back a review-gated correction case.
974
+
975
+ The active daemon derives reviewer identity and profile from its local
976
+ authenticated MCP boundary. Clients provide only a case address, a
977
+ CAS version, and an optional reviewer-approved event-time boundary.
978
+ """
979
+ if action not in {"apply", "reject", "rollback"}:
980
+ return {"success": False, "error": "action must be apply, reject, or rollback"}
981
+ if not isinstance(expected_version, int) or isinstance(expected_version, bool):
982
+ return {"success": False, "error": "expected_version must be an integer"}
983
+ if expected_version < 0:
984
+ return {"success": False, "error": "expected_version must be non-negative"}
985
+ try:
986
+ import asyncio
987
+ import urllib.parse
988
+
989
+ from superlocalmemory.cli.daemon import daemon_request, is_daemon_running
990
+
991
+ if not await asyncio.to_thread(is_daemon_running):
992
+ return {
993
+ "success": False,
994
+ "retryable": True,
995
+ "error": "correction review requires the resident canonical daemon",
996
+ }
997
+ payload: dict[str, object] = {"expected_version": expected_version}
998
+ if event_valid_until is not None:
999
+ payload["event_valid_until"] = event_valid_until
1000
+ path = "/api/corrections/" + urllib.parse.quote(case_id, safe="") + "/" + action
1001
+ result = await asyncio.to_thread(daemon_request, "POST", path, payload)
1002
+ if isinstance(result, dict) and result.get("success"):
1003
+ return result
1004
+ return {
1005
+ "success": False,
1006
+ "retryable": True,
1007
+ "error": "resident daemon rejected the correction review",
1008
+ }
1009
+ except Exception:
1010
+ logger.exception("review_correction failed")
1011
+ return {"success": False, "retryable": True, "error": "correction review unavailable"}
1012
+
1013
+ @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
1014
+ async def list_corrections(limit: int = 100) -> dict:
1015
+ """List active-profile correction cases for a human or host reviewer."""
1016
+ if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 500:
1017
+ return {"success": False, "error": "limit must be an integer from 1 to 500"}
1018
+ try:
1019
+ import asyncio
1020
+
1021
+ from superlocalmemory.cli.daemon import daemon_request, is_daemon_running
1022
+
1023
+ if not await asyncio.to_thread(is_daemon_running):
1024
+ return {
1025
+ "success": False,
1026
+ "retryable": True,
1027
+ "error": "correction review requires the resident canonical daemon",
1028
+ }
1029
+ result = await asyncio.to_thread(daemon_request, "GET", f"/api/corrections?limit={limit}")
1030
+ if isinstance(result, dict) and result.get("success"):
1031
+ return result
1032
+ return {
1033
+ "success": False,
1034
+ "retryable": True,
1035
+ "error": "resident daemon rejected correction listing",
1036
+ }
1037
+ except Exception:
1038
+ logger.exception("list_corrections failed")
1039
+ return {"success": False, "retryable": True, "error": "correction listing unavailable"}
1040
+
956
1041
  @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
957
1042
  async def get_attribution() -> dict:
958
1043
  """Get system attribution: author, version, license, and provenance metadata."""
@@ -29,6 +29,7 @@ from superlocalmemory.core.config import ChannelWeights, RetrievalConfig
29
29
  from superlocalmemory.retrieval.fusion import FusionResult, weighted_rrf
30
30
  from superlocalmemory.retrieval.strategy import QueryStrategy, QueryStrategyClassifier
31
31
  from superlocalmemory.retrieval.temporal_validity_filter import (
32
+ CorrectionAdmissionCache,
32
33
  admit_correction_candidates,
33
34
  admit_correction_fusion_results,
34
35
  )
@@ -232,6 +233,10 @@ class RetrievalEngine:
232
233
  include_unknown=include_unknown,
233
234
  )
234
235
  _em("run_channels")
236
+ # One request may need admission before fusion and again after optional
237
+ # bridge/scene expansion. Cache only the IDs checked during this one
238
+ # request; every newly expanded candidate remains a hard DB lookup.
239
+ correction_admission = CorrectionAdmissionCache()
235
240
  if profile_hits:
236
241
  ch_results["profile"] = profile_hits
237
242
  # The profile shortcut bypasses _run_channels(), so it needs the same
@@ -241,6 +246,7 @@ class RetrievalEngine:
241
246
  known_as_of=known_as_of, valid_at=valid_at,
242
247
  include_unknown=include_unknown,
243
248
  include_global=include_global, include_shared=include_shared,
249
+ lifecycle_cache=correction_admission,
244
250
  )
245
251
  total = sum(len(v) for v in ch_results.values())
246
252
 
@@ -357,6 +363,7 @@ class RetrievalEngine:
357
363
  known_as_of=known_as_of, valid_at=valid_at,
358
364
  include_unknown=include_unknown,
359
365
  include_global=include_global, include_shared=include_shared,
366
+ lifecycle_cache=correction_admission,
360
367
  )
361
368
 
362
369
  _em("expand+entity_enh")
@@ -949,16 +956,6 @@ class RetrievalEngine:
949
956
  except Exception as exc:
950
957
  logger.warning("Post-retrieval filter failed: %s", exc)
951
958
 
952
- # The legacy temporal filter preserves its score-demotion semantics for
953
- # compatibility. Admission is separate and hard: no current
954
- # system-superseded fact may seed fusion, bridge discovery, or rerank.
955
- out = admit_correction_candidates(
956
- out, profile_id, self._db, as_of=as_of,
957
- known_as_of=known_as_of, valid_at=valid_at,
958
- include_unknown=include_unknown,
959
- include_global=include_global, include_shared=include_shared,
960
- )
961
-
962
959
  return out
963
960
 
964
961
  def close(self, *, wait: bool = False) -> None:
@@ -49,8 +49,12 @@ All demotions are non-destructive (P5-INT-01): facts stay in the candidate
49
49
  list but rank below valid facts. A factor of 0.0 restores the legacy hide
50
50
  behaviour (a score of zero is gated out by the evidence floor).
51
51
 
52
- Both lookups are bounded (candidate ids only), chunked, indexed, and
53
- fail-open: a DB error returns results unchanged.
52
+ All correction-admission lookups are bounded (candidate ids only), chunked,
53
+ and indexed. Admission is deliberately **fail-closed**: if SLM cannot prove
54
+ which candidates are invalidated, it returns no candidates rather than let an
55
+ approved stale fact re-enter recall. The legacy score-demotion filter follows
56
+ the same rule for its system-invalidated lookup. Event-time demotion remains a
57
+ best-effort ranking signal; it is not the correction authority.
54
58
 
55
59
  Integrates with ChannelRegistry.register_filter() using the FilterFn signature:
56
60
  (all_channel_results, profile_id, context) -> filtered_results
@@ -62,6 +66,7 @@ License: AGPL-3.0-or-later
62
66
  from __future__ import annotations
63
67
 
64
68
  import logging
69
+ from dataclasses import dataclass, field
65
70
  from typing import TYPE_CHECKING, Any
66
71
 
67
72
  if TYPE_CHECKING:
@@ -79,6 +84,21 @@ logger = logging.getLogger(__name__)
79
84
  _EVENT_TIME_DEMOTION_FACTOR: float = 0.5
80
85
 
81
86
 
87
+ @dataclass
88
+ class CorrectionAdmissionCache:
89
+ """Per-recall lifecycle admission cache.
90
+
91
+ The retrieval engine performs a mandatory second admission after bridge or
92
+ scene expansion. Facts already checked before fusion do not need a second
93
+ database read in the *same* recall, while any newly expanded id is checked
94
+ immediately. The cache never crosses requests, profiles, or DB writes.
95
+ """
96
+
97
+ checked_fact_ids: set[str] = field(default_factory=set)
98
+ inadmissible_fact_ids: set[str] = field(default_factory=set)
99
+ unavailable: bool = False
100
+
101
+
82
102
  def _normalized_as_of(as_of: str | None) -> str | None:
83
103
  """Normalize an optional transaction-time boundary at one boundary.
84
104
 
@@ -100,26 +120,69 @@ def _invalidated_candidate_ids(
100
120
  as_of: str | None = None,
101
121
  include_global: bool = False,
102
122
  include_shared: bool = False,
123
+ lifecycle_cache: CorrectionAdmissionCache | None = None,
103
124
  ) -> set[str] | None:
104
- """Return admissibility failures, or ``None`` when the lookup is unhealthy.
125
+ """Return correction failures, or ``None`` when admission is unprovable.
105
126
 
106
- ``None`` deliberately preserves the established fail-open availability
107
- contract. It is distinct from an empty set: a bad/mocked return value must
108
- not accidentally exclude arbitrary candidates.
127
+ ``None`` is intentionally distinct from an empty set. Callers MUST turn
128
+ it into an abstention (an empty candidate path), never treat it as "nothing
129
+ invalidated". Treating an unavailable lifecycle read as an empty set is a
130
+ fail-open path through which an approved stale fact can be re-admitted.
109
131
  """
110
132
  if not fact_ids:
111
133
  return set()
134
+ if lifecycle_cache is not None and lifecycle_cache.unavailable:
135
+ return None
136
+ unchecked = (
137
+ fact_ids - lifecycle_cache.checked_fact_ids
138
+ if lifecycle_cache is not None
139
+ else fact_ids
140
+ )
141
+ if not unchecked:
142
+ return (
143
+ lifecycle_cache.inadmissible_fact_ids & fact_ids
144
+ if lifecycle_cache is not None
145
+ else set()
146
+ )
112
147
  try:
113
148
  kwargs: dict[str, Any] = {"as_of": _normalized_as_of(as_of)}
114
149
  if include_global:
115
150
  kwargs["include_global"] = True
116
151
  if include_shared:
117
152
  kwargs["include_shared"] = True
118
- invalid = db.get_invalidated_fact_ids(list(fact_ids), profile_id, **kwargs)
153
+ # Do not infer support from a permissive mock's dynamic attributes.
154
+ # The concrete storage manager owns this optimized contract; older
155
+ # adapters continue through the two focused public queries below.
156
+ combined = getattr(type(db), "get_correction_inadmissible_fact_ids", None)
157
+ if callable(combined):
158
+ invalid = db.get_correction_inadmissible_fact_ids(
159
+ list(unchecked), profile_id, **kwargs,
160
+ )
161
+ else:
162
+ invalid = db.get_invalidated_fact_ids(list(unchecked), profile_id, **kwargs)
163
+ pending_successors = db.get_nonapplied_correction_successor_ids(
164
+ list(unchecked),
165
+ profile_id,
166
+ include_global=include_global,
167
+ include_shared=include_shared,
168
+ )
169
+ if not isinstance(pending_successors, set):
170
+ return None
171
+ invalid |= pending_successors
119
172
  except Exception as exc:
120
173
  logger.warning("Correction admission lookup failed: %s", exc)
174
+ if lifecycle_cache is not None:
175
+ lifecycle_cache.unavailable = True
121
176
  return None
122
- return invalid if isinstance(invalid, set) else None
177
+ if not isinstance(invalid, set):
178
+ if lifecycle_cache is not None:
179
+ lifecycle_cache.unavailable = True
180
+ return None
181
+ if lifecycle_cache is not None:
182
+ lifecycle_cache.checked_fact_ids.update(unchecked)
183
+ lifecycle_cache.inadmissible_fact_ids.update(invalid)
184
+ return lifecycle_cache.inadmissible_fact_ids & fact_ids
185
+ return invalid
123
186
 
124
187
 
125
188
  def _strict_temporal_candidate_ids(
@@ -133,7 +196,7 @@ def _strict_temporal_candidate_ids(
133
196
  include_global: bool = False,
134
197
  include_shared: bool = False,
135
198
  ) -> set[str] | None:
136
- """Return strict two-clock admission failures, fail-open on lookup error."""
199
+ """Return strict two-clock failures, or ``None`` when admission is unprovable."""
137
200
  if not fact_ids or (known_as_of is None and valid_at is None):
138
201
  return set()
139
202
  try:
@@ -151,6 +214,26 @@ def _strict_temporal_candidate_ids(
151
214
  return invalid if isinstance(invalid, set) else None
152
215
 
153
216
 
217
+ def _abstain_candidates(
218
+ all_results: dict[str, list[tuple[str, float]]],
219
+ *,
220
+ stage: str,
221
+ ) -> dict[str, list[tuple[str, float]]]:
222
+ """Fail closed without changing the retrieval filter public contract.
223
+
224
+ ``ChannelRegistry`` and the retrieval engine both expect the original
225
+ channel-result shape. Returning the same channel names with empty lists
226
+ is an explicit candidate-level abstention: downstream fusion naturally
227
+ produces no materializable facts, while callers keep their stable response
228
+ schema and can report ``no_confident_match``.
229
+ """
230
+ logger.error(
231
+ "Temporal correction admission unavailable at %s; abstaining from recall candidates",
232
+ stage,
233
+ )
234
+ return {channel_name: [] for channel_name in all_results}
235
+
236
+
154
237
  def admit_correction_candidates(
155
238
  all_results: dict[str, list[tuple[str, float]]],
156
239
  profile_id: str,
@@ -162,6 +245,7 @@ def admit_correction_candidates(
162
245
  include_unknown: bool = False,
163
246
  include_global: bool = False,
164
247
  include_shared: bool = False,
248
+ lifecycle_cache: CorrectionAdmissionCache | None = None,
165
249
  ) -> dict[str, list[tuple[str, float]]]:
166
250
  """Hard-exclude system-superseded facts before candidate fusion.
167
251
 
@@ -178,15 +262,20 @@ def admit_correction_candidates(
178
262
  invalid = _invalidated_candidate_ids(
179
263
  db, fact_ids, profile_id, as_of=as_of,
180
264
  include_global=include_global, include_shared=include_shared,
265
+ lifecycle_cache=lifecycle_cache,
181
266
  )
267
+ if invalid is None:
268
+ return _abstain_candidates(all_results, stage="pre_fusion.lifecycle")
182
269
  strict = _strict_temporal_candidate_ids(
183
270
  db, fact_ids, profile_id,
184
271
  known_as_of=known_as_of, valid_at=valid_at,
185
272
  include_unknown=include_unknown,
186
273
  include_global=include_global, include_shared=include_shared,
187
274
  )
275
+ if strict is None:
276
+ return _abstain_candidates(all_results, stage="pre_fusion.strict_temporal")
188
277
  if strict:
189
- invalid = (invalid or set()) | strict
278
+ invalid |= strict
190
279
  if not invalid:
191
280
  return all_results
192
281
  return {
@@ -210,21 +299,35 @@ def admit_correction_fusion_results(
210
299
  include_unknown: bool = False,
211
300
  include_global: bool = False,
212
301
  include_shared: bool = False,
302
+ lifecycle_cache: CorrectionAdmissionCache | None = None,
213
303
  ) -> list[Any]:
214
304
  """Re-apply correction admission after graph/scene candidate expansion."""
215
305
  fact_ids = {result.fact_id for result in fused_results}
216
306
  invalid = _invalidated_candidate_ids(
217
307
  db, fact_ids, profile_id, as_of=as_of,
218
308
  include_global=include_global, include_shared=include_shared,
309
+ lifecycle_cache=lifecycle_cache,
219
310
  )
311
+ if invalid is None:
312
+ logger.error(
313
+ "Temporal correction admission unavailable at post_fusion.lifecycle; "
314
+ "abstaining from recall candidates",
315
+ )
316
+ return []
220
317
  strict = _strict_temporal_candidate_ids(
221
318
  db, fact_ids, profile_id,
222
319
  known_as_of=known_as_of, valid_at=valid_at,
223
320
  include_unknown=include_unknown,
224
321
  include_global=include_global, include_shared=include_shared,
225
322
  )
323
+ if strict is None:
324
+ logger.error(
325
+ "Temporal correction admission unavailable at post_fusion.strict_temporal; "
326
+ "abstaining from recall candidates",
327
+ )
328
+ return []
226
329
  if strict:
227
- invalid = (invalid or set()) | strict
330
+ invalid |= strict
228
331
  if not invalid:
229
332
  return fused_results
230
333
  return [result for result in fused_results if result.fact_id not in invalid]
@@ -311,14 +414,11 @@ class TemporalValidityFilter:
311
414
  # When as_of is set: only supersessions that occurred AT OR BEFORE
312
415
  # as_of contribute (Phase 4b bi-temporal fix). Supersessions after
313
416
  # as_of are invisible — the fact was still valid at the query point.
314
- try:
315
- invalid = self._db.get_invalidated_fact_ids(
316
- list(all_fact_ids), profile_id, as_of=as_of,
317
- )
318
- except Exception as exc:
319
- # Fail-open: a validity-lookup error must never break retrieval.
320
- logger.warning("Temporal validity lookup failed: %s", exc)
321
- return all_results
417
+ invalid = _invalidated_candidate_ids(
418
+ self._db, all_fact_ids, profile_id, as_of=as_of,
419
+ )
420
+ if invalid is None:
421
+ return _abstain_candidates(all_results, stage="legacy_filter.lifecycle")
322
422
 
323
423
  # --- Axis 2: Event-time expiry (Phase 4 T1b) ---
324
424
  # Guard: skip event-time demotion when the caller signals it wants
@@ -52,6 +52,7 @@ from typing import Any
52
52
 
53
53
  from fastapi import APIRouter, Depends, HTTPException, Request
54
54
  from superlocalmemory import __version__
55
+ from superlocalmemory.brain import BrainTruthService
55
56
 
56
57
  from superlocalmemory.core.security_primitives import (
57
58
  redact_secrets,
@@ -1130,11 +1131,16 @@ async def get_brain(request: Request, profile_id: str | None = None) -> dict:
1130
1131
  # "default" — the Brain must reflect whichever profile is active.
1131
1132
  profile_id = _authorized_profile(request, profile_id)
1132
1133
  lrn_db = LearningDatabase(_learning_db_path())
1134
+ truth_service = BrainTruthService(
1135
+ memory_db_path=_memory_dir() / "memory.db",
1136
+ learning_db_path=_learning_db_path(),
1137
+ )
1133
1138
 
1134
1139
  (
1135
1140
  preferences, learning, usage, bandit_snap, cache,
1136
1141
  cross_platform, outcomes_preview, evolution, active_clients,
1137
1142
  feedback_loop, source_quality, graph_summary, agent_experience,
1143
+ brain_truth,
1138
1144
  ) = await asyncio.gather(
1139
1145
  asyncio.to_thread(_compute_preferences, profile_id),
1140
1146
  asyncio.to_thread(_compute_learning_status, profile_id, lrn_db),
@@ -1152,6 +1158,7 @@ async def get_brain(request: Request, profile_id: str | None = None) -> dict:
1152
1158
  asyncio.to_thread(_compute_source_quality, profile_id),
1153
1159
  asyncio.to_thread(_compute_graph_summary, profile_id),
1154
1160
  asyncio.to_thread(_compute_agent_experience, profile_id),
1161
+ asyncio.to_thread(truth_service.snapshot, profile_id),
1155
1162
  return_exceptions=True,
1156
1163
  )
1157
1164
 
@@ -1213,6 +1220,14 @@ async def get_brain(request: Request, profile_id: str | None = None) -> dict:
1213
1220
  "source_quality": source_quality,
1214
1221
  "graph": graph_summary,
1215
1222
  "agent_experience": agent_experience,
1223
+ # Additive v4.0.5 contract. Keep the preceding legacy keys for
1224
+ # existing dashboard/API clients; new clients should render this
1225
+ # one shared, unavailable-aware snapshot.
1226
+ "brain_truth": _ok(brain_truth, {
1227
+ "availability": "unavailable",
1228
+ "source": "BrainTruth service unavailable",
1229
+ "control_plane": "observation_only",
1230
+ }),
1216
1231
  "source": "local durable stores + ephemeral session registry",
1217
1232
  },
1218
1233
  "evolution_preview": _ok(evolution, {
@@ -1182,9 +1182,9 @@ async def merge_memory(request: Request, fact_id: str):
1182
1182
  raise _canonical_mutation_error(exc, "Merge error")
1183
1183
 
1184
1184
 
1185
- @router.patch("/api/memories/{fact_id}")
1185
+ @router.patch("/api/memories/{fact_id}", status_code=202)
1186
1186
  async def edit_memory(request: Request, fact_id: str):
1187
- """Edit the content of a specific memory (atomic fact)."""
1187
+ """Propose an immutable, review-required correction for one memory."""
1188
1188
  try:
1189
1189
  body = await request.json()
1190
1190
  new_content = (body.get("content") or "").strip()
@@ -1210,13 +1210,139 @@ async def edit_memory(request: Request, fact_id: str):
1210
1210
  )
1211
1211
  if not result.get("ok"):
1212
1212
  raise HTTPException(status_code=404, detail="Memory not found")
1213
- return {"success": True, "fact_id": fact_id, "content": new_content}
1213
+ if result.get("unchanged"):
1214
+ return {"success": True, "fact_id": fact_id, "content": new_content, "unchanged": True}
1215
+ correction = result["correction_case"]
1216
+ return {
1217
+ "success": True,
1218
+ "fact_id": fact_id,
1219
+ "predecessor_fact_id": result["predecessor_fact_id"],
1220
+ "successor_fact_id": result["successor_fact_id"],
1221
+ "correction_case": correction,
1222
+ "review_required": True,
1223
+ "status": "proposed",
1224
+ }
1214
1225
  except HTTPException:
1215
1226
  raise
1216
1227
  except Exception as exc:
1217
1228
  raise _canonical_mutation_error(exc, "Edit error")
1218
1229
 
1219
1230
 
1231
+ @router.post("/api/corrections/{case_id}/{action}")
1232
+ async def review_correction(request: Request, case_id: str, action: str):
1233
+ """Apply, reject, or roll back an active-profile correction case.
1234
+
1235
+ The caller authenticates through the daemon boundary. It cannot select a
1236
+ profile, fact scope, or trust tier; the canonical writer rechecks all of
1237
+ those fields in its one SQLite transaction.
1238
+ """
1239
+ try:
1240
+ body = await request.json()
1241
+ if action not in {"apply", "reject", "rollback"}:
1242
+ raise HTTPException(422, detail="action must be apply, reject, or rollback")
1243
+ expected_version = body.get("expected_version") if isinstance(body, dict) else None
1244
+ if not isinstance(expected_version, int) or isinstance(expected_version, bool):
1245
+ raise HTTPException(422, detail="expected_version must be a non-negative integer")
1246
+ if expected_version < 0:
1247
+ raise HTTPException(422, detail="expected_version must be a non-negative integer")
1248
+ event_valid_until = body.get("event_valid_until") if isinstance(body, dict) else None
1249
+ if event_valid_until is not None and not isinstance(event_valid_until, str):
1250
+ raise HTTPException(422, detail="event_valid_until must be an RFC3339 timestamp")
1251
+ if event_valid_until is not None and action != "apply":
1252
+ raise HTTPException(422, detail="event_valid_until is permitted only for apply")
1253
+ engine, active_profile, hook_context = _authorize_memory_mutation(
1254
+ request, "update", case_id, run_pre_hook=False
1255
+ )
1256
+ result = _canonical_mutation_runtime(request).transition_correction(
1257
+ active_profile,
1258
+ case_id,
1259
+ action=action,
1260
+ expected_version=expected_version,
1261
+ actor_id=hook_context["agent_id"],
1262
+ event_valid_until=event_valid_until,
1263
+ idempotency_key=_mutation_idempotency_key(request),
1264
+ )
1265
+ if not result.get("ok"):
1266
+ raise HTTPException(404, detail="Correction case not found")
1267
+ if action in {"apply", "rollback"}:
1268
+ from superlocalmemory.core.mutations import purge_profile_context_cache
1269
+
1270
+ purge_profile_context_cache(engine, active_profile)
1271
+ engine._hooks.run_post("update", hook_context)
1272
+ return {"success": True, "correction_case": result}
1273
+ except HTTPException:
1274
+ raise
1275
+ except Exception as exc:
1276
+ raise _canonical_mutation_error(exc, "Correction review error")
1277
+
1278
+
1279
+ def _correction_case_response(case) -> dict[str, object]:
1280
+ """Return review metadata only; correction ledgers never contain fact text."""
1281
+ return {
1282
+ "case_id": case.case_id,
1283
+ "profile_id": case.profile_id,
1284
+ "scope": case.scope,
1285
+ "predecessor_fact_id": case.predecessor_fact_id,
1286
+ "successor_fact_id": case.successor_fact_id,
1287
+ "reason_code": case.reason_code,
1288
+ "status": case.status,
1289
+ "version": case.version,
1290
+ "created_at": case.created_at,
1291
+ "updated_at": case.updated_at,
1292
+ "reviewed_at": case.reviewed_at,
1293
+ "applied_at": case.applied_at,
1294
+ "system_effective_at": case.system_effective_at,
1295
+ "event_valid_from": case.event_valid_from,
1296
+ "event_valid_until": case.event_valid_until,
1297
+ }
1298
+
1299
+
1300
+ def _correction_store_for(engine, active_profile: str):
1301
+ from superlocalmemory.storage.correction_cases import CorrectionCaseStore
1302
+
1303
+ return CorrectionCaseStore(
1304
+ engine._db.db_path,
1305
+ is_profile_active=lambda candidate: candidate == active_profile,
1306
+ # Read operations never invoke this callback; writes use the daemon's
1307
+ # canonical runtime, which derives the authenticated actor separately.
1308
+ is_actor_trusted=lambda _actor: False,
1309
+ )
1310
+
1311
+
1312
+ @router.get("/api/corrections")
1313
+ async def list_corrections(request: Request, limit: int = 100):
1314
+ """List bounded review metadata for the active owning profile."""
1315
+ try:
1316
+ engine, active_profile, _context = _authorize_memory_mutation(
1317
+ request, "update", "correction-list", run_pre_hook=False
1318
+ )
1319
+ cases = _correction_store_for(engine, active_profile).list_cases(active_profile, limit=limit)
1320
+ return {"success": True, "corrections": [_correction_case_response(case) for case in cases]}
1321
+ except HTTPException:
1322
+ raise
1323
+ except Exception as exc:
1324
+ raise _canonical_mutation_error(exc, "Correction list error")
1325
+
1326
+
1327
+ @router.get("/api/corrections/{case_id}")
1328
+ async def get_correction(request: Request, case_id: str):
1329
+ """Get one active-profile correction case without exposing raw memory text."""
1330
+ try:
1331
+ engine, active_profile, _context = _authorize_memory_mutation(
1332
+ request, "update", case_id, run_pre_hook=False
1333
+ )
1334
+ case = _correction_store_for(engine, active_profile).get_case(case_id)
1335
+ return {"success": True, "correction": _correction_case_response(case)}
1336
+ except HTTPException:
1337
+ raise
1338
+ except Exception as exc:
1339
+ from superlocalmemory.storage.correction_cases import CorrectionNotFoundError
1340
+
1341
+ if isinstance(exc, CorrectionNotFoundError):
1342
+ raise HTTPException(404, detail="Correction case not found") from exc
1343
+ raise _canonical_mutation_error(exc, "Correction lookup error")
1344
+
1345
+
1220
1346
  _VALID_SCOPES = ("personal", "shared", "global")
1221
1347
 
1222
1348
 
@@ -150,6 +150,9 @@ from superlocalmemory.storage.migrations import (
150
150
  from superlocalmemory.storage.migrations import (
151
151
  M041_external_evidence_receipts as _M041,
152
152
  )
153
+ from superlocalmemory.storage.migrations import (
154
+ M042_correction_case_ledger as _M042,
155
+ )
153
156
 
154
157
  # Emit under the runner's logger name so operational log filters that key on
155
158
  # "superlocalmemory.storage.migration_runner" keep matching after this split.
@@ -199,6 +202,7 @@ _MODULES = {
199
202
  _M039.NAME: _M039,
200
203
  _M040.NAME: _M040,
201
204
  _M041.NAME: _M041,
205
+ _M042.NAME: _M042,
202
206
  }
203
207
 
204
208
  # Exact historical DDL fingerprints whose resulting schema is intentionally
@@ -21,9 +21,9 @@ import sqlite3
21
21
  from pathlib import Path
22
22
 
23
23
  #: Highest schema_version this runner can write. Matches the trailing serial
24
- #: of the latest migration (M041). Increment when adding new migrations or
24
+ #: of the latest migration (M042). Increment when adding new migrations or
25
25
  #: table-level breaking changes.
26
- SUPPORTED_SCHEMA_VERSION: int = 41
26
+ SUPPORTED_SCHEMA_VERSION: int = 42
27
27
 
28
28
 
29
29
  class SchemaVersionError(RuntimeError):