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.
- package/CHANGELOG.md +90 -0
- package/README.md +23 -14
- package/ide/configs/codex-mcp.toml +2 -2
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -0
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +3 -2
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +2 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +6 -5
- package/plugin-src/skills/slm-graph/SKILL.md +2 -1
- package/plugin-src/skills/slm-profile/SKILL.md +1 -0
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +106 -0
- package/src/superlocalmemory/brain/__init__.py +5 -0
- package/src/superlocalmemory/brain/truth.py +418 -0
- package/src/superlocalmemory/cli/__main__.py +17 -0
- package/src/superlocalmemory/cli/commands.py +96 -28
- package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
- package/src/superlocalmemory/cli/gdpr_io.py +109 -0
- package/src/superlocalmemory/cli/main.py +88 -0
- package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
- package/src/superlocalmemory/code_graph/graph_store.py +180 -3
- package/src/superlocalmemory/code_graph/parser.py +280 -100
- package/src/superlocalmemory/compliance/gdpr.py +358 -0
- package/src/superlocalmemory/core/config.py +44 -1
- package/src/superlocalmemory/core/context_cache.py +58 -1
- package/src/superlocalmemory/core/engine_wiring.py +5 -1
- package/src/superlocalmemory/core/maintenance.py +43 -1
- package/src/superlocalmemory/core/mutations.py +155 -25
- package/src/superlocalmemory/core/recall_pipeline.py +6 -10
- package/src/superlocalmemory/core/recall_worker.py +33 -12
- package/src/superlocalmemory/core/remember_runtime.py +271 -2
- package/src/superlocalmemory/core/store_pipeline.py +100 -38
- package/src/superlocalmemory/encoding/consolidator.py +17 -47
- package/src/superlocalmemory/encoding/temporal_validator.py +14 -18
- package/src/superlocalmemory/hooks/user_prompt_hook.py +1 -1
- package/src/superlocalmemory/infra/backup.py +138 -0
- package/src/superlocalmemory/infra/backup_obligations.py +423 -0
- package/src/superlocalmemory/integrations/bounded_loops_mcp.py +4 -3
- package/src/superlocalmemory/learning/engagement.py +165 -0
- package/src/superlocalmemory/mcp/profiles.py +19 -7
- package/src/superlocalmemory/mcp/server.py +4 -2
- package/src/superlocalmemory/mcp/tools_brain.py +54 -10
- package/src/superlocalmemory/mcp/tools_code_graph.py +31 -4
- package/src/superlocalmemory/mcp/tools_core.py +88 -3
- package/src/superlocalmemory/mcp/tools_v3.py +20 -6
- package/src/superlocalmemory/retrieval/engine.py +28 -10
- package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +119 -19
- package/src/superlocalmemory/server/routes/brain.py +297 -14
- package/src/superlocalmemory/server/routes/learning.py +13 -25
- package/src/superlocalmemory/server/routes/memories.py +129 -3
- package/src/superlocalmemory/server/routes/v3_api.py +171 -60
- package/src/superlocalmemory/storage/_migration_internals.py +4 -0
- package/src/superlocalmemory/storage/_schema_version.py +2 -2
- package/src/superlocalmemory/storage/correction_cases.py +670 -0
- package/src/superlocalmemory/storage/database.py +230 -24
- package/src/superlocalmemory/storage/migration_runner.py +7 -0
- package/src/superlocalmemory/storage/migrations/M042_correction_case_ledger.py +245 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
- package/src/superlocalmemory/storage/models.py +12 -4
- package/src/superlocalmemory/storage/write_coordinator.py +4 -0
- package/src/superlocalmemory/summaries/__init__.py +37 -0
- package/src/superlocalmemory/summaries/base.py +108 -0
- package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
- package/src/superlocalmemory/summaries/project_work_log.py +424 -0
- package/src/superlocalmemory/summaries/session_summary.py +307 -0
- package/src/superlocalmemory/ui/css/design-system.css +76 -1
- package/src/superlocalmemory/ui/index.html +28 -11
- package/src/superlocalmemory/ui/js/brain.js +43 -7
- package/src/superlocalmemory/ui/js/od-agents.js +49 -5
- package/src/superlocalmemory/ui/js/od-brain.js +280 -84
- package/src/superlocalmemory/ui/js/od-graph.js +147 -6
|
@@ -14,6 +14,7 @@ from __future__ import annotations
|
|
|
14
14
|
import hashlib
|
|
15
15
|
import json
|
|
16
16
|
import logging
|
|
17
|
+
import sqlite3
|
|
17
18
|
import uuid
|
|
18
19
|
from typing import TYPE_CHECKING, Any
|
|
19
20
|
|
|
@@ -51,6 +52,72 @@ def _ingestion_effect_id(operation_id: str, *parts: object) -> str:
|
|
|
51
52
|
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
|
|
52
53
|
|
|
53
54
|
|
|
55
|
+
def _record_correction_candidate(
|
|
56
|
+
db: DatabaseManager,
|
|
57
|
+
*,
|
|
58
|
+
operation_id: str,
|
|
59
|
+
profile_id: str,
|
|
60
|
+
scope: str,
|
|
61
|
+
predecessor_fact_id: str,
|
|
62
|
+
successor_fact_id: str,
|
|
63
|
+
reason_code: str,
|
|
64
|
+
trusted_actor_id: str,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Append a review candidate through the current canonical transaction.
|
|
67
|
+
|
|
68
|
+
This intentionally carries identifiers and a controlled reason code only.
|
|
69
|
+
It must not use detector prose because that can contain user memory text.
|
|
70
|
+
If the current path is bound to the canonical coordinator,
|
|
71
|
+
``raw_connection`` yields its already-open transaction; otherwise the
|
|
72
|
+
database manager owns the short transaction. Either path is atomic.
|
|
73
|
+
"""
|
|
74
|
+
if not trusted_actor_id or not predecessor_fact_id or not successor_fact_id:
|
|
75
|
+
return
|
|
76
|
+
if predecessor_fact_id == successor_fact_id:
|
|
77
|
+
return
|
|
78
|
+
from superlocalmemory.storage.correction_cases import (
|
|
79
|
+
CorrectionActor,
|
|
80
|
+
CorrectionCaseError,
|
|
81
|
+
propose_on_connection,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
case_id = _ingestion_effect_id(
|
|
85
|
+
operation_id, "correction-case", profile_id, predecessor_fact_id,
|
|
86
|
+
successor_fact_id, reason_code,
|
|
87
|
+
)
|
|
88
|
+
idempotency_key = _ingestion_effect_id(
|
|
89
|
+
operation_id, "correction-proposal", profile_id, predecessor_fact_id,
|
|
90
|
+
successor_fact_id, reason_code,
|
|
91
|
+
)
|
|
92
|
+
actor = CorrectionActor(
|
|
93
|
+
actor_id=trusted_actor_id,
|
|
94
|
+
actor_kind="host_attested",
|
|
95
|
+
trust_tier="canonical_writer",
|
|
96
|
+
)
|
|
97
|
+
try:
|
|
98
|
+
with db.raw_connection() as conn:
|
|
99
|
+
propose_on_connection(
|
|
100
|
+
conn,
|
|
101
|
+
case_id=case_id,
|
|
102
|
+
profile_id=profile_id,
|
|
103
|
+
scope=scope,
|
|
104
|
+
predecessor_fact_id=predecessor_fact_id,
|
|
105
|
+
successor_fact_id=successor_fact_id,
|
|
106
|
+
reason_code=reason_code,
|
|
107
|
+
actor=actor,
|
|
108
|
+
idempotency_key=idempotency_key,
|
|
109
|
+
# Candidate detection observes a possible correction. It does
|
|
110
|
+
# not claim an event-time boundary from heuristic evidence.
|
|
111
|
+
is_profile_active=lambda candidate_profile: candidate_profile == profile_id,
|
|
112
|
+
is_actor_trusted=lambda candidate_actor: candidate_actor == actor,
|
|
113
|
+
)
|
|
114
|
+
except (CorrectionCaseError, sqlite3.Error, ValueError) as exc:
|
|
115
|
+
# A missing/hot-upgrading M042 ledger must not make memory ingestion
|
|
116
|
+
# unavailable. The candidate is advisory and has no retrieval effect;
|
|
117
|
+
# the warning is the operational signal that an operator must inspect.
|
|
118
|
+
logger.warning("Correction candidate not recorded for %s: %s", successor_fact_id, exc)
|
|
119
|
+
|
|
120
|
+
|
|
54
121
|
def _record_fact_entity_association(
|
|
55
122
|
db: DatabaseManager,
|
|
56
123
|
*,
|
|
@@ -731,47 +798,30 @@ def run_store(
|
|
|
731
798
|
continue
|
|
732
799
|
fact = existing_fact
|
|
733
800
|
|
|
734
|
-
# Opinion confidence tracking: reinforce or decay
|
|
735
|
-
if fact.fact_type == FactType.OPINION and action.action_type.value == "update":
|
|
736
|
-
try:
|
|
737
|
-
existing = db.get_fact(
|
|
738
|
-
action.existing_fact_id or action.new_fact_id
|
|
739
|
-
)
|
|
740
|
-
if existing and existing.fact_type == FactType.OPINION:
|
|
741
|
-
new_conf = min(1.0, existing.confidence + 0.1)
|
|
742
|
-
db.update_fact(existing.fact_id, {"confidence": new_conf})
|
|
743
|
-
except Exception:
|
|
744
|
-
pass
|
|
745
|
-
elif fact.fact_type == FactType.OPINION and action.action_type.value == "supersede":
|
|
746
|
-
try:
|
|
747
|
-
old_id = getattr(action, "old_fact_id", None)
|
|
748
|
-
if old_id:
|
|
749
|
-
old_fact = db.get_fact(old_id)
|
|
750
|
-
if old_fact:
|
|
751
|
-
new_conf = max(0.0, old_fact.confidence - 0.2)
|
|
752
|
-
db.update_fact(old_id, {"confidence": new_conf})
|
|
753
|
-
except Exception:
|
|
754
|
-
pass
|
|
755
|
-
|
|
756
801
|
if action.action_type.value in ("update", "supersede"):
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
updated_fact = db.get_fact(target_id)
|
|
765
|
-
if updated_fact is None:
|
|
802
|
+
# A consolidator UPDATE/SUPERSEDE is a review-required
|
|
803
|
+
# proposal. It persists the incoming successor and does
|
|
804
|
+
# not mutate, delete, archive, or change trust on the
|
|
805
|
+
# matched predecessor. Continue materializing the
|
|
806
|
+
# incoming fact through the common projection pipeline.
|
|
807
|
+
proposed_fact = db.get_fact(action.new_fact_id)
|
|
808
|
+
if proposed_fact is None:
|
|
766
809
|
raise RuntimeError(
|
|
767
|
-
f"consolidation {action.action_type.value} produced "
|
|
768
|
-
f"missing fact {
|
|
810
|
+
f"consolidation {action.action_type.value} proposal produced "
|
|
811
|
+
f"missing fact {action.new_fact_id}"
|
|
812
|
+
)
|
|
813
|
+
fact = proposed_fact
|
|
814
|
+
if action.existing_fact_id:
|
|
815
|
+
_record_correction_candidate(
|
|
816
|
+
db,
|
|
817
|
+
operation_id=ingestion_operation_id,
|
|
818
|
+
profile_id=profile_id,
|
|
819
|
+
scope=scope,
|
|
820
|
+
predecessor_fact_id=action.existing_fact_id,
|
|
821
|
+
successor_fact_id=action.new_fact_id,
|
|
822
|
+
reason_code=f"consolidation_{action.action_type.value}",
|
|
823
|
+
trusted_actor_id=trusted_actor_id,
|
|
769
824
|
)
|
|
770
|
-
# Continue through the shared index/graph/temporal/
|
|
771
|
-
# provenance stages. The previous early continue made
|
|
772
|
-
# UPDATE/SUPERSEDE facts look stored while skipping half of
|
|
773
|
-
# canonical materialization.
|
|
774
|
-
fact = updated_fact
|
|
775
825
|
# ADD case: consolidator already stored the fact (F8 fix)
|
|
776
826
|
# Fall through to post-processing below
|
|
777
827
|
else:
|
|
@@ -860,6 +910,18 @@ def run_store(
|
|
|
860
910
|
"Temporal: %d facts invalidated by new fact %s",
|
|
861
911
|
len(invalidations), fact.fact_id,
|
|
862
912
|
)
|
|
913
|
+
for candidate in invalidations:
|
|
914
|
+
predecessor_fact_id = str(candidate.get("old_fact_id") or "")
|
|
915
|
+
_record_correction_candidate(
|
|
916
|
+
db,
|
|
917
|
+
operation_id=ingestion_operation_id,
|
|
918
|
+
profile_id=profile_id,
|
|
919
|
+
scope=scope,
|
|
920
|
+
predecessor_fact_id=predecessor_fact_id,
|
|
921
|
+
successor_fact_id=fact.fact_id,
|
|
922
|
+
reason_code="temporal_contradiction",
|
|
923
|
+
trusted_actor_id=trusted_actor_id,
|
|
924
|
+
)
|
|
863
925
|
except Exception as exc:
|
|
864
926
|
temporal_complete = False
|
|
865
927
|
logger.debug(
|
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
"""SuperLocalMemory V3 — Memory Consolidator.
|
|
6
6
|
|
|
7
|
-
Mem0-style ADD/UPDATE/SUPERSEDE/NOOP
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
Mem0-style ADD/UPDATE/SUPERSEDE/NOOP classification for incoming facts.
|
|
8
|
+
|
|
9
|
+
UPDATE and SUPERSEDE are now *proposal classifications*: they persist the
|
|
10
|
+
incoming fact but never rewrite, archive, lower trust for, or otherwise mutate
|
|
11
|
+
the matched fact. A reviewed correction owner may later apply a case through
|
|
12
|
+
the correction ledger. This keeps ingestion useful while preventing an
|
|
13
|
+
automatic model judgement from changing the historical record.
|
|
11
14
|
|
|
12
15
|
Mode A: keyword-based contradiction detection (zero LLM).
|
|
13
16
|
Mode B/C: LLM-assisted contradiction detection when available.
|
|
@@ -20,7 +23,7 @@ from __future__ import annotations
|
|
|
20
23
|
|
|
21
24
|
import logging
|
|
22
25
|
import math
|
|
23
|
-
from typing import
|
|
26
|
+
from typing import Protocol
|
|
24
27
|
|
|
25
28
|
from superlocalmemory.core.config import EncodingConfig
|
|
26
29
|
from superlocalmemory.storage.database import DatabaseManager
|
|
@@ -30,7 +33,6 @@ from superlocalmemory.storage.models import (
|
|
|
30
33
|
ConsolidationActionType,
|
|
31
34
|
EdgeType,
|
|
32
35
|
GraphEdge,
|
|
33
|
-
MemoryLifecycle,
|
|
34
36
|
)
|
|
35
37
|
|
|
36
38
|
logger = logging.getLogger(__name__)
|
|
@@ -310,29 +312,17 @@ class MemoryConsolidator:
|
|
|
310
312
|
*,
|
|
311
313
|
reason: str,
|
|
312
314
|
) -> ConsolidationAction:
|
|
313
|
-
"""
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
updates: dict[str, Any] = {
|
|
317
|
-
"evidence_count": new_evidence,
|
|
318
|
-
"confidence": new_confidence,
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
# If LLM available, merge content for a richer fact
|
|
322
|
-
if self._llm is not None and self._llm.is_available():
|
|
323
|
-
merged = self._merge_facts(existing.content, new_fact.content)
|
|
324
|
-
if merged:
|
|
325
|
-
updates["content"] = merged
|
|
326
|
-
|
|
327
|
-
self._db.update_fact(existing.fact_id, updates)
|
|
315
|
+
"""Persist a proposed refinement without rewriting the predecessor."""
|
|
316
|
+
self._db.store_fact(new_fact)
|
|
317
|
+
self._create_semantic_edges(new_fact, profile_id)
|
|
328
318
|
action = self._log_action(
|
|
329
319
|
ConsolidationActionType.UPDATE,
|
|
330
320
|
new_fact.fact_id, existing.fact_id,
|
|
331
321
|
profile_id, reason,
|
|
332
322
|
)
|
|
333
323
|
logger.debug(
|
|
334
|
-
"UPDATE
|
|
335
|
-
existing.fact_id,
|
|
324
|
+
"UPDATE proposal %s -> %s: %s",
|
|
325
|
+
existing.fact_id, new_fact.fact_id, reason,
|
|
336
326
|
)
|
|
337
327
|
return action
|
|
338
328
|
|
|
@@ -344,37 +334,17 @@ class MemoryConsolidator:
|
|
|
344
334
|
*,
|
|
345
335
|
reason: str,
|
|
346
336
|
) -> ConsolidationAction:
|
|
347
|
-
"""
|
|
348
|
-
# Archive old fact (keep for history but deprioritize in retrieval)
|
|
349
|
-
self._db.update_fact(
|
|
350
|
-
existing.fact_id,
|
|
351
|
-
{"lifecycle": MemoryLifecycle.ARCHIVED},
|
|
352
|
-
)
|
|
353
|
-
# Store new fact
|
|
337
|
+
"""Persist a proposed successor without changing the predecessor."""
|
|
354
338
|
self._db.store_fact(new_fact)
|
|
355
|
-
|
|
356
|
-
self._db.store_edge(GraphEdge(
|
|
357
|
-
profile_id=profile_id,
|
|
358
|
-
source_id=new_fact.fact_id,
|
|
359
|
-
target_id=existing.fact_id,
|
|
360
|
-
edge_type=EdgeType.CONTRADICTION,
|
|
361
|
-
weight=1.0,
|
|
362
|
-
))
|
|
363
|
-
self._db.store_edge(GraphEdge(
|
|
364
|
-
profile_id=profile_id,
|
|
365
|
-
source_id=new_fact.fact_id,
|
|
366
|
-
target_id=existing.fact_id,
|
|
367
|
-
edge_type=EdgeType.SUPERSEDES,
|
|
368
|
-
weight=1.0,
|
|
369
|
-
))
|
|
339
|
+
self._create_semantic_edges(new_fact, profile_id)
|
|
370
340
|
action = self._log_action(
|
|
371
341
|
ConsolidationActionType.SUPERSEDE,
|
|
372
342
|
new_fact.fact_id, existing.fact_id,
|
|
373
343
|
profile_id, reason,
|
|
374
344
|
)
|
|
375
345
|
logger.debug(
|
|
376
|
-
"SUPERSEDE %s
|
|
377
|
-
|
|
346
|
+
"SUPERSEDE proposal %s -> %s: %s",
|
|
347
|
+
existing.fact_id, new_fact.fact_id, reason,
|
|
378
348
|
)
|
|
379
349
|
return action
|
|
380
350
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
3
|
# Part of SuperLocalMemory V3
|
|
4
4
|
|
|
5
|
-
"""Temporal Intelligence -- contradiction detection and
|
|
5
|
+
"""Temporal Intelligence -- contradiction detection and reviewable proposals.
|
|
6
6
|
|
|
7
7
|
Implements full bi-temporal validity tracking with 4 timestamps (L8 fix).
|
|
8
8
|
Contradiction detection via sheaf cohomology (Mode A: pure math) or
|
|
@@ -36,7 +36,7 @@ logger = logging.getLogger(__name__)
|
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
class TemporalValidator:
|
|
39
|
-
"""Validates temporal consistency and
|
|
39
|
+
"""Validates temporal consistency and proposes fact corrections.
|
|
40
40
|
|
|
41
41
|
Components received via __init__ (NOT the engine -- Rule 06):
|
|
42
42
|
- db: DatabaseManager
|
|
@@ -75,15 +75,20 @@ class TemporalValidator:
|
|
|
75
75
|
new_fact: AtomicFact,
|
|
76
76
|
profile_id: str,
|
|
77
77
|
) -> list[dict]:
|
|
78
|
-
"""Check new fact for contradictions
|
|
78
|
+
"""Check new fact for contradictions without mutating old facts.
|
|
79
79
|
|
|
80
80
|
Algorithm:
|
|
81
81
|
1. Detect contradictions (sheaf or LLM).
|
|
82
|
-
2.
|
|
83
|
-
3.
|
|
84
|
-
4. Return list of invalidation actions.
|
|
82
|
+
2. Exclude valid historical progressions using explicit event anchors.
|
|
83
|
+
3. Return review-required correction candidates.
|
|
85
84
|
|
|
86
|
-
|
|
85
|
+
This legacy method name is intentionally retained for API compatibility.
|
|
86
|
+
It no longer expires facts, changes trust, or changes retrieval state.
|
|
87
|
+
The reviewed-correction owner is the only layer permitted to apply a
|
|
88
|
+
candidate to bi-temporal validity.
|
|
89
|
+
|
|
90
|
+
Returns list of dicts: {old_fact_id, new_fact_id, reason, severity,
|
|
91
|
+
status="proposed"}.
|
|
87
92
|
"""
|
|
88
93
|
contradictions = self.detect_contradiction(new_fact, profile_id)
|
|
89
94
|
|
|
@@ -108,25 +113,16 @@ class TemporalValidator:
|
|
|
108
113
|
)
|
|
109
114
|
continue
|
|
110
115
|
|
|
111
|
-
# Step 1: Invalidate the old fact (bi-temporal)
|
|
112
|
-
self.invalidate_fact(
|
|
113
|
-
fact_id=old_fact_id,
|
|
114
|
-
invalidated_by=new_fact.fact_id,
|
|
115
|
-
reason=reason,
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
# Step 2: Apply trust penalty
|
|
119
|
-
self._apply_trust_penalty(old_fact_id, profile_id)
|
|
120
|
-
|
|
121
116
|
actions.append({
|
|
122
117
|
"old_fact_id": old_fact_id,
|
|
123
118
|
"new_fact_id": new_fact.fact_id,
|
|
124
119
|
"reason": reason,
|
|
125
120
|
"severity": severity,
|
|
121
|
+
"status": "proposed",
|
|
126
122
|
})
|
|
127
123
|
|
|
128
124
|
logger.info(
|
|
129
|
-
"Temporal:
|
|
125
|
+
"Temporal: proposed %d correction(s) due to new fact %s",
|
|
130
126
|
len(actions), new_fact.fact_id,
|
|
131
127
|
)
|
|
132
128
|
return actions
|
|
@@ -98,7 +98,7 @@ def main() -> int:
|
|
|
98
98
|
|
|
99
99
|
try:
|
|
100
100
|
topic_sig = compute_topic_signature(prompt, entity_hits=entity_hits)
|
|
101
|
-
entry = read_entry_fast(session_id, topic_sig)
|
|
101
|
+
entry = read_entry_fast(session_id, topic_sig, require_current_admission=True)
|
|
102
102
|
except Exception:
|
|
103
103
|
sys.stdout.write("{}")
|
|
104
104
|
return 0
|
|
@@ -25,6 +25,10 @@ from datetime import datetime, timedelta, timezone
|
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
from typing import Dict, Generator, List, Optional
|
|
27
27
|
|
|
28
|
+
from superlocalmemory.infra.backup_obligations import (
|
|
29
|
+
BackupObligationStore,
|
|
30
|
+
erase_profile_from_snapshot,
|
|
31
|
+
)
|
|
28
32
|
from superlocalmemory.infra.data_root import DynamicStatePath, canonical_data_root
|
|
29
33
|
|
|
30
34
|
logger = logging.getLogger("superlocalmemory.backup")
|
|
@@ -387,6 +391,21 @@ class BackupCoordinator:
|
|
|
387
391
|
# Phase C succeeded — remove pre-restore snapshots.
|
|
388
392
|
self._cleanup_pre_restore_snapshots(pre_restore_map, pre_restore_lance)
|
|
389
393
|
|
|
394
|
+
# Phase D — GDPR obligation replay (invariant: no restore may resurrect
|
|
395
|
+
# erased personal data). Must run AFTER live files are in place and
|
|
396
|
+
# pre-restore snapshots are removed so there is no window where the
|
|
397
|
+
# erased data could be read. A replay failure raises immediately; the
|
|
398
|
+
# caller receives BackupRestoreError and must treat the restore as
|
|
399
|
+
# failed until an operator manually remediates.
|
|
400
|
+
if backup_set_dir is not None:
|
|
401
|
+
_replay_obligations_after_restore(
|
|
402
|
+
data_root=self._base_dir,
|
|
403
|
+
snapshot_key=str(backup_set_dir),
|
|
404
|
+
restored_db_paths=[
|
|
405
|
+
self._base_dir / e.store_name for e in manifest.stores
|
|
406
|
+
],
|
|
407
|
+
)
|
|
408
|
+
|
|
390
409
|
# ------------------------------------------------------------------
|
|
391
410
|
# Internal helpers (factored out for subclass testability)
|
|
392
411
|
# ------------------------------------------------------------------
|
|
@@ -727,6 +746,24 @@ class BackupManager:
|
|
|
727
746
|
src.close()
|
|
728
747
|
|
|
729
748
|
logger.info("Restored: %s -> %s", filename, target.name)
|
|
749
|
+
|
|
750
|
+
# GDPR obligation replay — prevent a restore from resurrecting
|
|
751
|
+
# previously erased personal data. Failure is fatal: return False
|
|
752
|
+
# so the caller knows the restore is not clean.
|
|
753
|
+
try:
|
|
754
|
+
_replay_obligations_after_restore(
|
|
755
|
+
data_root=self.db_path.parent,
|
|
756
|
+
snapshot_key=str(backup_path),
|
|
757
|
+
restored_db_paths=[target],
|
|
758
|
+
)
|
|
759
|
+
except BackupRestoreError as exc:
|
|
760
|
+
logger.error(
|
|
761
|
+
"Restore obligation replay failed for %s: %s — "
|
|
762
|
+
"restore is NOT clean; erased data may be present",
|
|
763
|
+
filename, exc,
|
|
764
|
+
)
|
|
765
|
+
return False
|
|
766
|
+
|
|
730
767
|
return True
|
|
731
768
|
|
|
732
769
|
except Exception as exc:
|
|
@@ -798,3 +835,104 @@ class BackupManager:
|
|
|
798
835
|
"total_size_mb": round(sum(b["size_mb"] for b in backups), 2),
|
|
799
836
|
"backups": backups,
|
|
800
837
|
}
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
# ---------------------------------------------------------------------------
|
|
841
|
+
# GDPR obligation replay — module-level so both backup classes can call it
|
|
842
|
+
# ---------------------------------------------------------------------------
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _replay_obligations_after_restore(
|
|
846
|
+
data_root: Path,
|
|
847
|
+
snapshot_key: str,
|
|
848
|
+
restored_db_paths: list[Path],
|
|
849
|
+
) -> None:
|
|
850
|
+
"""Re-apply pending erasure obligations to freshly restored database files.
|
|
851
|
+
|
|
852
|
+
This function is the enforcement point for the restore-replay invariant:
|
|
853
|
+
no restore may resurface data that was Art.17-erased from the live stores.
|
|
854
|
+
|
|
855
|
+
``snapshot_key`` is the string path that was recorded as the obligation's
|
|
856
|
+
``snapshot_path`` when the obligation was created (typically the backup-set
|
|
857
|
+
directory for new-style backups, or the per-file `.db` path for legacy
|
|
858
|
+
backups).
|
|
859
|
+
|
|
860
|
+
Raises:
|
|
861
|
+
BackupRestoreError: if any obligation cannot be replayed. The caller
|
|
862
|
+
must treat the restore as failed and surface this to the operator.
|
|
863
|
+
"""
|
|
864
|
+
store = BackupObligationStore(data_root)
|
|
865
|
+
obligations = store.list_pending_for_snapshot(snapshot_key)
|
|
866
|
+
|
|
867
|
+
# Belt-and-suspenders: even when path matching fails (e.g. backup moved),
|
|
868
|
+
# detect erased profiles by scanning the restored DB and checking whether
|
|
869
|
+
# any of the profiles present have pending obligations.
|
|
870
|
+
if not obligations:
|
|
871
|
+
for db_path in restored_db_paths:
|
|
872
|
+
if not db_path.exists():
|
|
873
|
+
continue
|
|
874
|
+
try:
|
|
875
|
+
import sqlite3 as _sq3
|
|
876
|
+
conn = _sq3.connect(str(db_path))
|
|
877
|
+
try:
|
|
878
|
+
tables = {
|
|
879
|
+
r[0] for r in
|
|
880
|
+
conn.execute(
|
|
881
|
+
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
882
|
+
).fetchall()
|
|
883
|
+
}
|
|
884
|
+
for tbl in ("profiles", "atomic_facts"):
|
|
885
|
+
if tbl not in tables:
|
|
886
|
+
continue
|
|
887
|
+
cols = {
|
|
888
|
+
r[1] for r in
|
|
889
|
+
conn.execute(f"PRAGMA table_info({tbl})").fetchall()
|
|
890
|
+
}
|
|
891
|
+
if "profile_id" not in cols:
|
|
892
|
+
continue
|
|
893
|
+
for (pid,) in conn.execute(
|
|
894
|
+
f"SELECT DISTINCT profile_id FROM {tbl}"
|
|
895
|
+
).fetchall():
|
|
896
|
+
if pid:
|
|
897
|
+
obligations.extend(
|
|
898
|
+
store.list_pending_for_profile(pid)
|
|
899
|
+
)
|
|
900
|
+
finally:
|
|
901
|
+
conn.close()
|
|
902
|
+
except Exception as exc: # noqa: BLE001
|
|
903
|
+
logger.warning(
|
|
904
|
+
"_replay_obligations_after_restore: scan failed for %s: %s",
|
|
905
|
+
db_path.name, exc,
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
if not obligations:
|
|
909
|
+
return
|
|
910
|
+
|
|
911
|
+
profile_ids = {o["profile_id"] for o in obligations}
|
|
912
|
+
|
|
913
|
+
for profile_id in profile_ids:
|
|
914
|
+
for db_path in restored_db_paths:
|
|
915
|
+
if not db_path.exists():
|
|
916
|
+
continue
|
|
917
|
+
try:
|
|
918
|
+
deleted = erase_profile_from_snapshot(db_path, profile_id)
|
|
919
|
+
if deleted:
|
|
920
|
+
logger.info(
|
|
921
|
+
"Restore replay: erased profile %r from %s: %s",
|
|
922
|
+
profile_id, db_path.name, deleted,
|
|
923
|
+
)
|
|
924
|
+
except Exception as exc: # noqa: BLE001
|
|
925
|
+
raise BackupRestoreError(
|
|
926
|
+
f"Erasure obligation replay failed for profile {profile_id!r} "
|
|
927
|
+
f"in {db_path.name}: {exc}. "
|
|
928
|
+
"The restored database may contain previously erased personal "
|
|
929
|
+
"data. Manual remediation required."
|
|
930
|
+
) from exc
|
|
931
|
+
|
|
932
|
+
# Discharge obligations for this specific snapshot so they do not block
|
|
933
|
+
# the completeness flag for future erasures of the same profile.
|
|
934
|
+
discharged = store.discharge_for_snapshot(snapshot_key, "replayed_on_restore")
|
|
935
|
+
logger.info(
|
|
936
|
+
"Obligation replay: discharged %d obligations for snapshot %s",
|
|
937
|
+
discharged, snapshot_key,
|
|
938
|
+
)
|