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
@@ -110,12 +110,30 @@ def is_remote_cross_encoder_backend(backend: str) -> bool:
110
110
  return (backend or "").strip().lower() in REMOTE_CROSS_ENCODER_BACKENDS
111
111
 
112
112
 
113
- def validate_remote_reranker_config(backend: str, endpoint: str) -> str | None:
113
+ def validate_remote_reranker_config(
114
+ backend: str,
115
+ endpoint: str,
116
+ trust_plain_http_lan: bool = True,
117
+ ) -> str | None:
114
118
  """Return an actionable error string, or None when the pair is coherent.
115
119
 
116
120
  Covers the issue-#103 leftover directly: an endpoint configured against a
117
121
  LOCAL backend used to be dropped on the floor by ``SLMConfig.load``. It now
118
122
  produces a named error naming both keys and the exact edit to make.
123
+
124
+ Args:
125
+ backend: Value of ``retrieval.cross_encoder_backend``.
126
+ endpoint: Value of ``retrieval.cross_encoder_endpoint``.
127
+ trust_plain_http_lan: When True (the default), numeric RFC1918/ULA/
128
+ link-local addresses may use plain HTTP — the same security posture
129
+ as the local reranker, where memory text only crosses loopback.
130
+ Set to False in hardened deployments (zero-trust networks, shared
131
+ colocation) to require HTTPS for all non-loopback hosts.
132
+
133
+ Threat model note: trusting a private-LAN address does NOT prevent a
134
+ MITM attack by an adversary on the same physical LAN (e.g. via ARP
135
+ spoofing). This flag means "the LAN is under my control and I accept that
136
+ risk." It is not a claim that RFC1918 traffic is cryptographically secure.
119
137
  """
120
138
  backend = (backend or "").strip()
121
139
  endpoint = (endpoint or "").strip()
@@ -139,11 +157,23 @@ def validate_remote_reranker_config(backend: str, endpoint: str) -> str | None:
139
157
  )
140
158
  if not remote:
141
159
  return None
142
- return _validate_endpoint_url(endpoint)
143
-
144
-
145
- def _validate_endpoint_url(endpoint: str) -> str | None:
146
- """Scheme/host allow-listing for the operator-supplied rerank URL."""
160
+ return _validate_endpoint_url(endpoint, trust_plain_http_lan=trust_plain_http_lan)
161
+
162
+
163
+ def _validate_endpoint_url(
164
+ endpoint: str,
165
+ trust_plain_http_lan: bool = True,
166
+ ) -> str | None:
167
+ """Scheme/host allow-listing for the operator-supplied rerank URL.
168
+
169
+ Plain-HTTP allowances (most-to-least trusted):
170
+ 1. Loopback (127.x, ::1, localhost) — always allowed.
171
+ 2. Numeric RFC1918/ULA/link-local addresses — allowed when
172
+ ``trust_plain_http_lan`` is True (the default). Only numeric
173
+ addresses qualify; bare hostnames are never trusted because DNS is
174
+ mutable and not a trust boundary.
175
+ 3. Everything else (public IPs, bare hostnames) — always requires HTTPS.
176
+ """
147
177
  try:
148
178
  parsed = urlparse(endpoint)
149
179
  except ValueError as exc:
@@ -177,11 +207,39 @@ def _validate_endpoint_url(endpoint: str) -> str | None:
177
207
  "retrieval.cross_encoder_api_key; it is sent as a Bearer header "
178
208
  "and never logged."
179
209
  )
180
- if parsed.scheme == "http" and not _is_loopback_host(parsed.hostname):
210
+ if parsed.scheme == "http":
211
+ hostname = parsed.hostname
212
+ if _is_loopback_host(hostname):
213
+ return None # loopback always allowed regardless of trust flag
214
+ if trust_plain_http_lan and _is_private_lan_host(hostname):
215
+ # Numeric private address on an operator-trusted LAN. Threat model:
216
+ # an attacker on the same physical LAN can still MITM plain HTTP
217
+ # (ARP spoofing). This is allowed because the LAN is assumed to be
218
+ # under the operator's control. Set trust_plain_http_lan=False in
219
+ # hardened/zero-trust environments.
220
+ return None
221
+ if not _is_private_lan_host(hostname):
222
+ # Public IP, CGNAT, or a bare hostname (DNS not trusted as a
223
+ # proof of locality). Bare hostnames that happen to resolve to
224
+ # private IPs are NOT trusted: DNS can be poisoned or changed,
225
+ # so only provably-private numeric addresses are accepted.
226
+ return (
227
+ "retrieval.cross_encoder_endpoint must use HTTPS for this "
228
+ "host. Plain HTTP is allowed only for loopback "
229
+ "(127.x/::1/localhost) and numeric private-LAN addresses "
230
+ "(RFC1918: 10.x, 172.16-31.x, 192.168.x; IPv6 ULA fc00::/7; "
231
+ "link-local 169.254.x/fe80::). "
232
+ "Bare hostnames are not trusted even if they resolve to a "
233
+ "private IP — use a numeric address or configure HTTPS."
234
+ )
235
+ # Private-LAN address but trust_plain_http_lan is False (hardened mode)
181
236
  return (
182
- "retrieval.cross_encoder_endpoint must use HTTPS for non-loopback "
183
- "hosts because recall queries and candidate memory text cross this "
184
- "connection. Plain HTTP is allowed only for localhost/loopback."
237
+ "retrieval.cross_encoder_endpoint uses plain HTTP to a "
238
+ "private-LAN address. HTTPS is required because "
239
+ "retrieval.trust_plain_http_lan is set to false. "
240
+ "Either configure a TLS-terminating proxy on the reranker, or "
241
+ "set retrieval.trust_plain_http_lan=true to permit plain HTTP "
242
+ "within your private network (default for new installs)."
185
243
  )
186
244
  return None
187
245
 
@@ -197,6 +255,42 @@ def _is_loopback_host(hostname: str) -> bool:
197
255
  return False
198
256
 
199
257
 
258
+ def _is_private_lan_host(hostname: str) -> bool:
259
+ """True only for numeric private-range addresses (RFC1918, ULA, link-local).
260
+
261
+ Deliberate non-DNS: bare hostnames (e.g. ``my-reranker.lan``) return False
262
+ even if they currently resolve to a private IP. DNS is mutable and not a
263
+ trust boundary — an adversary who can influence DNS resolution can redirect
264
+ the endpoint to a public host, defeating the locality check. Only numeric
265
+ addresses are provably bound to a private range at configuration time.
266
+
267
+ Accepted ranges (Python 3.11+ ``ipaddress.is_private``):
268
+ IPv4 RFC1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
269
+ IPv4 link-local: 169.254.0.0/16
270
+ IPv6 ULA: fc00::/7 (includes fd00::/8)
271
+ IPv6 link-local: fe80::/10
272
+
273
+ Excluded ranges (not accepted for plain HTTP):
274
+ CGNAT 100.64.0.0/10 — ISP-shared address space, not operator-controlled
275
+ 172.15.0.0/8 and 172.32.0.0/8 — outside the 172.16.0.0/12 boundary
276
+ Public unicast addresses
277
+
278
+ IPv4-mapped IPv6 addresses (``::ffff:192.168.1.1``) are unwrapped to their
279
+ IPv4 equivalent before the range check, so they are handled consistently.
280
+ """
281
+ host = (hostname or "").rstrip(".").lower()
282
+ try:
283
+ addr = ipaddress.ip_address(host)
284
+ except ValueError:
285
+ # Not a numeric address — bare hostname, not provably private
286
+ return False
287
+ # Unwrap IPv4-mapped IPv6 (::ffff:192.168.1.1 → 192.168.1.1) so the
288
+ # RFC1918 check applies to the IPv4 portion.
289
+ if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
290
+ addr = addr.ipv4_mapped
291
+ return addr.is_private
292
+
293
+
200
294
  def normalize_rerank_endpoint(endpoint: str) -> str:
201
295
  """Append ``/rerank`` when the URL stops at the API root.
202
296
 
@@ -370,8 +464,11 @@ class RemoteReranker:
370
464
  api_key: str = "",
371
465
  backend: str = "openai",
372
466
  timeout_seconds: float = _DEFAULT_READ_TIMEOUT_S,
467
+ trust_plain_http_lan: bool = True,
373
468
  ) -> None:
374
- error = validate_remote_reranker_config(backend, endpoint)
469
+ error = validate_remote_reranker_config(
470
+ backend, endpoint, trust_plain_http_lan=trust_plain_http_lan,
471
+ )
375
472
  if error:
376
473
  raise RemoteRerankerConfigError(error)
377
474
 
@@ -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