superlocalmemory 4.0.3 → 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 (74) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +19 -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 +5 -4
  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 +7 -6
  26. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-graph/SKILL.md +3 -2
  30. package/plugin-src/skills/slm-loop/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  32. package/plugin-src/skills/slm-profile/SKILL.md +5 -4
  33. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  36. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  37. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  38. package/pyproject.toml +1 -1
  39. package/src/superlocalmemory/__init__.py +1 -1
  40. package/src/superlocalmemory/brain/__init__.py +5 -0
  41. package/src/superlocalmemory/brain/truth.py +348 -0
  42. package/src/superlocalmemory/cli/commands.py +82 -25
  43. package/src/superlocalmemory/cli/main.py +12 -0
  44. package/src/superlocalmemory/core/context_cache.py +58 -1
  45. package/src/superlocalmemory/core/mutations.py +155 -25
  46. package/src/superlocalmemory/core/recall_pipeline.py +6 -10
  47. package/src/superlocalmemory/core/remember_runtime.py +271 -2
  48. package/src/superlocalmemory/core/store_pipeline.py +100 -38
  49. package/src/superlocalmemory/encoding/consolidator.py +17 -47
  50. package/src/superlocalmemory/encoding/temporal_validator.py +14 -18
  51. package/src/superlocalmemory/hooks/user_prompt_hook.py +1 -1
  52. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +185 -0
  53. package/src/superlocalmemory/learning/database.py +2 -1
  54. package/src/superlocalmemory/mcp/profiles.py +25 -7
  55. package/src/superlocalmemory/mcp/server.py +7 -2
  56. package/src/superlocalmemory/mcp/tools_brain.py +138 -9
  57. package/src/superlocalmemory/mcp/tools_core.py +88 -3
  58. package/src/superlocalmemory/retrieval/engine.py +7 -10
  59. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +119 -19
  60. package/src/superlocalmemory/server/routes/brain.py +21 -1
  61. package/src/superlocalmemory/server/routes/memories.py +129 -3
  62. package/src/superlocalmemory/storage/_migration_internals.py +8 -0
  63. package/src/superlocalmemory/storage/_schema_version.py +2 -2
  64. package/src/superlocalmemory/storage/agent_experience.py +26 -4
  65. package/src/superlocalmemory/storage/correction_cases.py +670 -0
  66. package/src/superlocalmemory/storage/database.py +194 -24
  67. package/src/superlocalmemory/storage/external_evidence.py +359 -0
  68. package/src/superlocalmemory/storage/migration_runner.py +12 -0
  69. package/src/superlocalmemory/storage/migrations/M041_external_evidence_receipts.py +189 -0
  70. package/src/superlocalmemory/storage/migrations/M042_correction_case_ledger.py +245 -0
  71. package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
  72. package/src/superlocalmemory/storage/write_coordinator.py +4 -0
  73. package/src/superlocalmemory/ui/js/brain.js +43 -7
  74. package/src/superlocalmemory/ui/js/od-brain.js +44 -19
@@ -77,13 +77,13 @@ def reset_engine():
77
77
 
78
78
  # Register tools and resources -------------------------------------------------
79
79
  #
80
- # Essential-only default: 38 base tools + 8 mesh tools = 46 registered
80
+ # Essential-only default: 41 base tools + 8 mesh tools = 49 registered.
81
81
  # when mesh is enabled. Set ``SLM_MCP_ALL_TOOLS=1`` to expose the full
82
82
  # toolset. Rationale: IDEs cap at 50-100 tools total (Cursor,
83
83
  # Antigravity, Windsurf) and a maximal SLM registration crowds out
84
84
  # other MCP servers the user may have installed.
85
85
  # Admin/diagnostics tools remain available via CLI (`slm <command>`).
86
- # Set SLM_MCP_ALL_TOOLS=1 to enable all 91 tools (power users).
86
+ # Set SLM_MCP_ALL_TOOLS=1 to enable all 94 tools (power users).
87
87
 
88
88
  import os as _os_reg
89
89
 
@@ -100,6 +100,11 @@ _ESSENTIAL_TOOLS: set[str] = {
100
100
  # v4.0.2 portable Brain evidence: profile-scoped receipt reads/writes.
101
101
  "get_brain_evidence_status", "record_agent_experience",
102
102
  "record_cognitive_turn", "finalize_cognitive_turn",
103
+ # v4.0.4: explicit, optional observation from the separately installed
104
+ # Bounded Loops MCP producer. It never participates in recall/ranking.
105
+ "observe_bounded_loop_evidence",
106
+ # Update, review, and list form one core correction lifecycle.
107
+ "review_correction", "list_corrections",
103
108
  # Memory management (2)
104
109
  "forget", "run_maintenance",
105
110
  # NOTE: prestage_context IS registered (see register_prestage_tool below)
@@ -9,21 +9,29 @@ or changes a memory answer.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import asyncio
13
+ import sqlite3
12
14
  from pathlib import Path
13
15
  from typing import Any, Callable
14
16
 
15
17
  from mcp.types import ToolAnnotations
16
18
 
19
+ from superlocalmemory.brain.truth import BrainTruthService
17
20
  from superlocalmemory.core.admission import admits
18
21
  from superlocalmemory.core.operation_request import OperationKind
19
22
  from superlocalmemory.infra.data_root import state_path
23
+ from superlocalmemory.integrations.bounded_loops_mcp import BridgeUnavailable, observe_installed
20
24
  from superlocalmemory.storage.agent_experience import (
21
25
  AgentExperienceConflictError,
22
26
  AgentExperienceStore,
23
27
  CognitiveTurnTransitionError,
24
28
  LearningWriteBusyError,
25
29
  ProfileAdmissionError,
26
- get_profile_receipt_summary,
30
+ )
31
+ from superlocalmemory.storage.external_evidence import (
32
+ ExternalEvidenceConflictError,
33
+ ExternalEvidenceStore,
34
+ ExternalEvidenceValidationError,
27
35
  )
28
36
 
29
37
 
@@ -35,6 +43,56 @@ def _store_for(engine: Any) -> AgentExperienceStore:
35
43
  )
36
44
 
37
45
 
46
+ def _external_store_for(engine: Any) -> ExternalEvidenceStore:
47
+ active_profile = engine.profile_id
48
+ return ExternalEvidenceStore(
49
+ Path(state_path("learning.db")),
50
+ is_profile_active=lambda profile_id: profile_id == active_profile,
51
+ )
52
+
53
+
54
+ def _brain_truth_for(engine: Any) -> dict[str, Any]:
55
+ """Read the portable truth snapshot without opening an engine or a writer."""
56
+ return BrainTruthService(
57
+ memory_db_path=state_path("memory.db"),
58
+ learning_db_path=state_path("learning.db"),
59
+ ).snapshot(engine.profile_id)
60
+
61
+
62
+ def _legacy_agent_experience(truth: dict[str, Any]) -> dict[str, Any]:
63
+ """Keep the v4.0.4 MCP alias during the one-release transition window."""
64
+ evidence = truth["agent_experience"]
65
+ available = evidence["availability"] == "available"
66
+ claimed = evidence["claimed_experiences_total"]
67
+ turns = evidence["cognitive_turns_total"]
68
+ states = evidence["cognitive_turns_by_state"]
69
+ return {
70
+ "is_real": available,
71
+ "availability": evidence["availability"],
72
+ "experiences_total": claimed if available else 0,
73
+ "turns_total": turns if available else 0,
74
+ "turns_by_state": states if available else {},
75
+ # The old name remains an alias only. BrainTruth deliberately calls
76
+ # these declared claims, never independently verified learning.
77
+ "claimed_evidence_experiences": claimed if available else 0,
78
+ "source": evidence["source"],
79
+ }
80
+
81
+
82
+ def _legacy_external_evidence(truth: dict[str, Any]) -> dict[str, Any]:
83
+ """Keep the v4.0.4 external-graph alias without a second database read."""
84
+ evidence = truth["external_evidence"]
85
+ available = evidence["availability"] == "available"
86
+ return {
87
+ "is_real": available,
88
+ "availability": evidence["availability"],
89
+ "total": evidence["receipts_total"] if available else 0,
90
+ "by_run_state": evidence["receipts_by_run_state"] if available else {},
91
+ "demonstrations": evidence["demonstrations_total"] if available else 0,
92
+ "control_plane": "observation_only",
93
+ }
94
+
95
+
38
96
  def _require_active_profile(engine: Any, payload: dict[str, Any]) -> str | None:
39
97
  supplied = payload.get("profile_id")
40
98
  if supplied != engine.profile_id:
@@ -42,6 +100,27 @@ def _require_active_profile(engine: Any, payload: dict[str, Any]) -> str | None:
42
100
  return None
43
101
 
44
102
 
103
+ class _ExternalEvidenceWriteError(Exception):
104
+ """Preserve committed receipt count when a later snapshot item fails."""
105
+
106
+ def __init__(self, created: int, cause: Exception) -> None:
107
+ super().__init__(str(cause))
108
+ self.created = created
109
+ self.cause = cause
110
+
111
+
112
+ def _record_external_evidence(store: ExternalEvidenceStore, observed: list[dict[str, Any]]) -> int:
113
+ """Write bounded evidence off the async MCP loop; return durable inserts."""
114
+ created = 0
115
+ for payload in observed:
116
+ try:
117
+ if store.record(payload):
118
+ created += 1
119
+ except Exception as exc:
120
+ raise _ExternalEvidenceWriteError(created, exc) from exc
121
+ return created
122
+
123
+
45
124
  def register_brain_tools(server: Any, get_engine: Callable[[], Any]) -> None:
46
125
  """Register transport-neutral receipt reads and writes.
47
126
 
@@ -52,15 +131,21 @@ def register_brain_tools(server: Any, get_engine: Callable[[], Any]) -> None:
52
131
 
53
132
  @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
54
133
  async def get_brain_evidence_status() -> dict[str, Any]:
55
- """Get profile-scoped Agent Experience and Cognitive Turn totals."""
134
+ """Get profile-scoped, observation-only Living Brain evidence totals.
135
+
136
+ ``brain_truth`` is the canonical v1 payload. The legacy aliases are
137
+ retained for one release so existing hosts can move independently.
138
+ """
56
139
  engine = get_engine()
140
+ truth = _brain_truth_for(engine)
57
141
  return {
58
142
  "success": True,
59
143
  "profile_id": engine.profile_id,
60
- "agent_experience": get_profile_receipt_summary(
61
- state_path("learning.db"), engine.profile_id
62
- ),
63
- "control_plane": "observation_only",
144
+ "brain_truth": truth,
145
+ "agent_experience": _legacy_agent_experience(truth),
146
+ "external_evidence": truth["external_evidence"],
147
+ "external_graph_evidence": _legacy_external_evidence(truth),
148
+ "control_plane": truth["control_plane"],
64
149
  }
65
150
 
66
151
  @server.tool()
@@ -110,9 +195,7 @@ def register_brain_tools(server: Any, get_engine: Callable[[], Any]) -> None:
110
195
 
111
196
  @server.tool()
112
197
  @admits(OperationKind.REMEMBER)
113
- async def finalize_cognitive_turn(
114
- receipt_id: str, outcome: dict[str, Any]
115
- ) -> dict[str, Any]:
198
+ async def finalize_cognitive_turn(receipt_id: str, outcome: dict[str, Any]) -> dict[str, Any]:
116
199
  """Finalize an active-profile cognitive turn with outcome evidence."""
117
200
  engine = get_engine()
118
201
  try:
@@ -130,3 +213,49 @@ def register_brain_tools(server: Any, get_engine: Callable[[], Any]) -> None:
130
213
  except (TypeError, ValueError) as exc:
131
214
  return {"success": False, "durable": False, "error": str(exc)}
132
215
  return {"success": True, "durable": True, "finalized": finalized}
216
+
217
+ @server.tool()
218
+ @admits(OperationKind.REMEMBER)
219
+ async def observe_bounded_loop_evidence(workspace: str) -> dict[str, Any]:
220
+ """Import one explicit, read-only snapshot from installed Bounded Loops.
221
+
222
+ Bounded Loops remains optional. This tool negotiates its public MCP
223
+ contract at runtime, records only compatible terminal evidence, and
224
+ never changes recall, ranking, routing, or learned behaviour.
225
+ """
226
+ engine = get_engine()
227
+ created = 0
228
+ try:
229
+ observed = await observe_installed(workspace=workspace, profile_id=engine.profile_id)
230
+ store = _external_store_for(engine)
231
+ created = await asyncio.to_thread(_record_external_evidence, store, observed)
232
+ except _ExternalEvidenceWriteError as exc:
233
+ return {
234
+ "success": False,
235
+ "durable": exc.created > 0,
236
+ "created": exc.created,
237
+ "retryable": isinstance(exc.cause, LearningWriteBusyError),
238
+ "error": str(exc.cause),
239
+ }
240
+ except (
241
+ BridgeUnavailable,
242
+ ExternalEvidenceConflictError,
243
+ ExternalEvidenceValidationError,
244
+ ProfileAdmissionError,
245
+ sqlite3.Error,
246
+ ) as exc:
247
+ return {
248
+ "success": False,
249
+ "durable": created > 0,
250
+ "created": created,
251
+ "error": str(exc),
252
+ }
253
+ except LearningWriteBusyError as exc:
254
+ return {"success": False, "durable": False, "retryable": True, "error": str(exc)}
255
+ return {
256
+ "success": True,
257
+ "durable": True,
258
+ "observed": len(observed),
259
+ "created": created,
260
+ "control_plane": "observation_only",
261
+ }
@@ -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