superlocalmemory 3.8.13 → 4.0.0
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/ATTRIBUTION.md +4 -4
- package/CHANGELOG.md +113 -121
- package/README.md +65 -63
- package/docs/pi-dev-integration.md +1 -1
- package/package.json +6 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- 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 +1 -1
- 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 +1 -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 +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +248 -0
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +99 -0
- package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
- package/plugin-src/skills/slm-profile/SKILL.md +148 -0
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-scope/SKILL.md +176 -0
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +11 -4
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +125 -11
- package/src/superlocalmemory/cli/daemon.py +5 -1
- package/src/superlocalmemory/cli/main.py +35 -2
- package/src/superlocalmemory/cli/ops_cmd.py +281 -0
- package/src/superlocalmemory/cli/setup_wizard.py +1 -1
- package/src/superlocalmemory/compliance/audit.py +65 -0
- package/src/superlocalmemory/compliance/eu_ai_act.py +27 -57
- package/src/superlocalmemory/compliance/gdpr.py +416 -20
- package/src/superlocalmemory/compliance/retention.py +74 -22
- package/src/superlocalmemory/compliance/scheduler.py +78 -9
- package/src/superlocalmemory/core/actor_context.py +166 -0
- package/src/superlocalmemory/core/admission.py +549 -0
- package/src/superlocalmemory/core/backend_orchestrator.py +23 -10
- package/src/superlocalmemory/core/config.py +202 -24
- package/src/superlocalmemory/core/consolidation_engine.py +13 -13
- package/src/superlocalmemory/core/context_cache.py +28 -0
- package/src/superlocalmemory/core/embeddings.py +64 -2
- package/src/superlocalmemory/core/engine.py +7 -2
- package/src/superlocalmemory/core/engine_ingestion.py +65 -3
- package/src/superlocalmemory/core/engine_wiring.py +36 -9
- package/src/superlocalmemory/core/ingest_policy.py +38 -0
- package/src/superlocalmemory/core/maintenance.py +255 -0
- package/src/superlocalmemory/core/modes.py +40 -13
- package/src/superlocalmemory/core/mutations.py +437 -44
- package/src/superlocalmemory/core/operation_policy.py +92 -0
- package/src/superlocalmemory/core/operation_policy_registry.py +542 -0
- package/src/superlocalmemory/core/operation_request.py +127 -0
- package/src/superlocalmemory/core/ops_remediation.py +542 -0
- package/src/superlocalmemory/core/recall_pipeline.py +7 -0
- package/src/superlocalmemory/core/remember_runtime.py +202 -4
- package/src/superlocalmemory/core/remote_mode.py +20 -5
- package/src/superlocalmemory/core/store_pipeline.py +150 -0
- package/src/superlocalmemory/core/topic_signature.py +19 -4
- package/src/superlocalmemory/core/transactions/__init__.py +78 -0
- package/src/superlocalmemory/core/transactions/concrete_owners.py +597 -0
- package/src/superlocalmemory/core/transactions/erasure.py +825 -0
- package/src/superlocalmemory/core/transactions/manifest.py +255 -0
- package/src/superlocalmemory/core/transactions/manifest_key.py +155 -0
- package/src/superlocalmemory/core/transactions/obligations.py +272 -0
- package/src/superlocalmemory/core/transactions/owners.py +114 -0
- package/src/superlocalmemory/core/transactions/reconciler.py +285 -0
- package/src/superlocalmemory/core/transactions/service.py +330 -0
- package/src/superlocalmemory/core/worker_pool.py +33 -5
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +70 -28
- package/src/superlocalmemory/encoding/emotional.py +75 -14
- package/src/superlocalmemory/encoding/scene_builder.py +115 -13
- package/src/superlocalmemory/encoding/temporal_parser.py +4 -0
- package/src/superlocalmemory/evolution/blind_verifier.py +11 -4
- package/src/superlocalmemory/evolution/evolution_store.py +244 -4
- package/src/superlocalmemory/evolution/llm_dispatch.py +40 -0
- package/src/superlocalmemory/evolution/model_selection.py +18 -3
- package/src/superlocalmemory/evolution/mutation_generator.py +3 -0
- package/src/superlocalmemory/evolution/skill_activator.py +270 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +281 -59
- package/src/superlocalmemory/evolution/types.py +30 -8
- package/src/superlocalmemory/graph/cozo_backend.py +17 -9
- package/src/superlocalmemory/hooks/auto_invoker.py +2 -1
- package/src/superlocalmemory/hooks/auto_recall.py +64 -30
- package/src/superlocalmemory/hooks/codex_assets.py +14 -1
- package/src/superlocalmemory/infra/backup.py +434 -7
- package/src/superlocalmemory/infra/process_reaper.py +18 -0
- package/src/superlocalmemory/infra/self_heal.py +401 -0
- package/src/superlocalmemory/learning/feedback.py +52 -9
- package/src/superlocalmemory/loops/engine.py +10 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
- package/src/superlocalmemory/mcp/http_transport.py +30 -331
- package/src/superlocalmemory/mcp/profiles.py +5 -0
- package/src/superlocalmemory/mcp/resources.py +8 -0
- package/src/superlocalmemory/mcp/server.py +51 -4
- package/src/superlocalmemory/mcp/shared.py +19 -0
- package/src/superlocalmemory/mcp/tools_active.py +25 -4
- package/src/superlocalmemory/mcp/tools_code_graph.py +26 -18
- package/src/superlocalmemory/mcp/tools_context.py +50 -8
- package/src/superlocalmemory/mcp/tools_core.py +69 -21
- package/src/superlocalmemory/mcp/tools_evolution.py +9 -2
- package/src/superlocalmemory/mcp/tools_learning.py +21 -10
- package/src/superlocalmemory/mcp/tools_loops.py +29 -18
- package/src/superlocalmemory/mcp/tools_mesh.py +8 -0
- package/src/superlocalmemory/mcp/tools_ops.py +115 -0
- package/src/superlocalmemory/mcp/tools_optimize.py +4 -0
- package/src/superlocalmemory/mcp/tools_v28.py +10 -3
- package/src/superlocalmemory/mcp/tools_v3.py +34 -14
- package/src/superlocalmemory/mcp/tools_v33.py +18 -33
- package/src/superlocalmemory/mesh/broker.py +124 -46
- package/src/superlocalmemory/mesh/broker_security.py +470 -0
- package/src/superlocalmemory/mesh/discovery.py +365 -0
- package/src/superlocalmemory/mesh/lock_protocol.py +313 -0
- package/src/superlocalmemory/mesh/node_identity.py +97 -0
- package/src/superlocalmemory/mesh/outbox_remote.py +429 -0
- package/src/superlocalmemory/mesh/remote_sync.py +511 -28
- package/src/superlocalmemory/mesh/state_sync.py +286 -0
- package/src/superlocalmemory/optimize/config/store.py +45 -0
- package/src/superlocalmemory/parameterization/cross_project.py +12 -0
- package/src/superlocalmemory/parameterization/prompt_injector.py +13 -11
- package/src/superlocalmemory/parameterization/prompt_lifecycle.py +8 -2
- package/src/superlocalmemory/parameterization/workflow_miner.py +17 -0
- package/src/superlocalmemory/retrieval/ann_index.py +5 -0
- package/src/superlocalmemory/retrieval/bm25_channel.py +49 -2
- package/src/superlocalmemory/retrieval/engine.py +19 -4
- package/src/superlocalmemory/retrieval/fusion.py +4 -1
- package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -3
- package/src/superlocalmemory/retrieval/remote_reranker.py +47 -22
- package/src/superlocalmemory/retrieval/reranker.py +32 -1
- package/src/superlocalmemory/retrieval/temporal_channel.py +16 -3
- package/src/superlocalmemory/retrieval/temporal_utils.py +107 -0
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +155 -42
- package/src/superlocalmemory/retrieval/vector_store.py +214 -8
- package/src/superlocalmemory/server/api.py +5 -5
- package/src/superlocalmemory/server/egress_policy.py +258 -0
- package/src/superlocalmemory/server/rbac_enforce.py +32 -0
- package/src/superlocalmemory/server/route_mutations.py +20 -0
- package/src/superlocalmemory/server/routes/compliance.py +153 -7
- package/src/superlocalmemory/server/routes/data_io.py +43 -2
- package/src/superlocalmemory/server/routes/events.py +15 -0
- package/src/superlocalmemory/server/routes/memories.py +56 -3
- package/src/superlocalmemory/server/routes/mesh.py +82 -1
- package/src/superlocalmemory/server/routes/mesh_lock.py +54 -0
- package/src/superlocalmemory/server/routes/mesh_state.py +63 -0
- package/src/superlocalmemory/server/routes/v3_api.py +50 -27
- package/src/superlocalmemory/server/routes/ws.py +86 -0
- package/src/superlocalmemory/server/ui.py +6 -6
- package/src/superlocalmemory/server/unified_daemon.py +942 -119
- package/src/superlocalmemory/storage/_migration_internals.py +568 -0
- package/src/superlocalmemory/storage/_schema_version.py +110 -0
- package/src/superlocalmemory/storage/database.py +329 -24
- package/src/superlocalmemory/storage/embedding_migrator.py +246 -51
- package/src/superlocalmemory/storage/erasure_fence.py +45 -0
- package/src/superlocalmemory/storage/generation_fence.py +63 -0
- package/src/superlocalmemory/storage/migration_runner.py +140 -417
- package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +40 -0
- package/src/superlocalmemory/storage/migrations/M033_projection_transactions.py +148 -0
- package/src/superlocalmemory/storage/migrations/M034_obligation_integrity.py +58 -0
- package/src/superlocalmemory/storage/migrations/M035_erasure_receipts.py +113 -0
- package/src/superlocalmemory/storage/migrations/M036_vector_row_map.py +107 -0
- package/src/superlocalmemory/storage/migrations/M037_manifest_hmac_version.py +162 -0
- package/src/superlocalmemory/storage/migrations/{M033_learning_feedback_channel.py → M038_learning_feedback_channel.py} +3 -3
- package/src/superlocalmemory/storage/migrations/M039_scene_fact_members.py +137 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +4 -2
- package/src/superlocalmemory/storage/schema.py +67 -0
- package/src/superlocalmemory/storage/write_coordinator.py +125 -0
- package/src/superlocalmemory/trust/scorer.py +28 -4
- package/src/superlocalmemory/ui/index.html +14 -3
- package/src/superlocalmemory/ui/js/auto-settings.js +12 -1
- package/src/superlocalmemory/ui/js/brain.js +6 -4
- package/src/superlocalmemory/ui/js/compliance.js +66 -12
- package/src/superlocalmemory/ui/js/dashboard.js +13 -3
- package/src/superlocalmemory/ui/js/feedback.js +8 -2
- package/src/superlocalmemory/ui/js/lifecycle.js +7 -1
- package/src/superlocalmemory/ui/js/modal.js +272 -5
- package/src/superlocalmemory/ui/js/od-backup.js +9 -2
- package/src/superlocalmemory/ui/js/od-compliance-ext.js +301 -0
- package/src/superlocalmemory/ui/js/od-operations.js +154 -23
- package/src/superlocalmemory/ui/js/od-ops-health.js +417 -0
- package/src/superlocalmemory/ui/js/od-optimize.js +35 -21
- package/src/superlocalmemory/ui/js/od-team.js +9 -2
- package/src/superlocalmemory/ui/js/optimize.js +13 -16
- package/src/superlocalmemory/ui/js/profiles.js +7 -3
- package/src/superlocalmemory/ui/js/settings.js +7 -1
- package/src/superlocalmemory/vector/lancedb_backend.py +19 -9
- package/src/superlocalmemory/attribution/mathematical_dna.py +0 -235
- package/src/superlocalmemory/cli/post_install.py +0 -114
- package/src/superlocalmemory/core/clock_monitor.py +0 -45
- package/src/superlocalmemory/core/db_pool.py +0 -80
- package/src/superlocalmemory/core/error_catalog.py +0 -113
- package/src/superlocalmemory/core/loop_watchdog.py +0 -56
- package/src/superlocalmemory/core/priority_queue.py +0 -61
- package/src/superlocalmemory/core/pruning_engine.py +0 -216
- package/src/superlocalmemory/core/queue_dispatcher.py +0 -73
- package/src/superlocalmemory/core/slmignore.py +0 -125
- package/src/superlocalmemory/infra/heartbeat_monitor.py +0 -140
- package/src/superlocalmemory/infra/webhook_dispatcher.py +0 -247
- package/src/superlocalmemory/learning/quantization_scheduler.py +0 -320
- package/src/superlocalmemory/storage/access_control.py +0 -182
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac as _hmac_mod
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
_RECEIPT_V1: int = 1
|
|
15
|
+
_RECEIPT_V2: int = 2
|
|
16
|
+
|
|
17
|
+
from superlocalmemory.core.transactions.obligations import ObligationLedger
|
|
18
|
+
from superlocalmemory.core.transactions.owners import (
|
|
19
|
+
ObligationKind,
|
|
20
|
+
ObligationState,
|
|
21
|
+
OperationContext,
|
|
22
|
+
ProjectionOwner,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("superlocalmemory.core.transactions.erasure")
|
|
26
|
+
|
|
27
|
+
MAX_ERASE_ATTEMPTS = 10
|
|
28
|
+
|
|
29
|
+
VALID_SUBJECT_TYPES = frozenset({"fact", "entity", "profile"})
|
|
30
|
+
|
|
31
|
+
AuditLogger = Callable[[Mapping[str, object]], None]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ErasureState:
|
|
35
|
+
COMPLETE = "COMPLETE"
|
|
36
|
+
FAILED = "FAILED"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class ErasureProofRecord:
|
|
41
|
+
owner: str
|
|
42
|
+
erased: bool
|
|
43
|
+
checksum: str
|
|
44
|
+
residue: tuple[str, ...] = ()
|
|
45
|
+
|
|
46
|
+
def as_dict(self) -> dict[str, object]:
|
|
47
|
+
return {
|
|
48
|
+
"owner": self.owner,
|
|
49
|
+
"erased": bool(self.erased),
|
|
50
|
+
"checksum": self.checksum,
|
|
51
|
+
"residue": list(self.residue),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class RemoveResult:
|
|
57
|
+
proofs: tuple["ErasureProofRecord", ...]
|
|
58
|
+
spine_ok: bool
|
|
59
|
+
tombstoned: bool
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class ErasureReceipt:
|
|
64
|
+
erasure_id: str
|
|
65
|
+
profile_id: str
|
|
66
|
+
subject_type: str
|
|
67
|
+
subject_id: str
|
|
68
|
+
requested_by: str
|
|
69
|
+
fact_count: int
|
|
70
|
+
state: str
|
|
71
|
+
all_erased: bool
|
|
72
|
+
proofs: tuple[ErasureProofRecord, ...]
|
|
73
|
+
audit_hash: str
|
|
74
|
+
requested_at: float
|
|
75
|
+
completed_at: float
|
|
76
|
+
persisted: bool
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _proof_dicts(proofs: Iterable[ErasureProofRecord]) -> list[dict[str, object]]:
|
|
80
|
+
return sorted((p.as_dict() for p in proofs), key=lambda d: d["owner"])
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def compute_erasure_hash(
|
|
84
|
+
*,
|
|
85
|
+
erasure_id: str,
|
|
86
|
+
profile_id: str,
|
|
87
|
+
subject_type: str,
|
|
88
|
+
subject_id: str,
|
|
89
|
+
requested_by: str,
|
|
90
|
+
fact_count: int,
|
|
91
|
+
state: str,
|
|
92
|
+
all_erased: bool,
|
|
93
|
+
evidence_json: str,
|
|
94
|
+
requested_at: float,
|
|
95
|
+
completed_at: float,
|
|
96
|
+
) -> str:
|
|
97
|
+
"""Unkeyed SHA-256 hash for erasure receipts (v1, backward-compat)."""
|
|
98
|
+
canonical = _erasure_canonical(
|
|
99
|
+
erasure_id=erasure_id,
|
|
100
|
+
profile_id=profile_id,
|
|
101
|
+
subject_type=subject_type,
|
|
102
|
+
subject_id=subject_id,
|
|
103
|
+
requested_by=requested_by,
|
|
104
|
+
fact_count=fact_count,
|
|
105
|
+
state=state,
|
|
106
|
+
all_erased=all_erased,
|
|
107
|
+
evidence_json=evidence_json,
|
|
108
|
+
requested_at=requested_at,
|
|
109
|
+
completed_at=completed_at,
|
|
110
|
+
)
|
|
111
|
+
return hashlib.sha256(canonical).hexdigest()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _erasure_canonical(
|
|
115
|
+
*,
|
|
116
|
+
erasure_id: str,
|
|
117
|
+
profile_id: str,
|
|
118
|
+
subject_type: str,
|
|
119
|
+
subject_id: str,
|
|
120
|
+
requested_by: str,
|
|
121
|
+
fact_count: int,
|
|
122
|
+
state: str,
|
|
123
|
+
all_erased: bool,
|
|
124
|
+
evidence_json: str,
|
|
125
|
+
requested_at: float,
|
|
126
|
+
completed_at: float,
|
|
127
|
+
receipt_version: int | None = None,
|
|
128
|
+
) -> bytes:
|
|
129
|
+
"""Produce the deterministic byte representation of the erasure receipt envelope.
|
|
130
|
+
|
|
131
|
+
When ``receipt_version`` is provided (v2+), it is bound into the canonical
|
|
132
|
+
bytes so a downgrade from v2 → v1 is detected by the MAC mismatch.
|
|
133
|
+
"""
|
|
134
|
+
envelope: dict[str, object] = {
|
|
135
|
+
"erasure_id": erasure_id,
|
|
136
|
+
"profile_id": profile_id,
|
|
137
|
+
"subject_type": subject_type,
|
|
138
|
+
"subject_id": subject_id,
|
|
139
|
+
"requested_by": requested_by,
|
|
140
|
+
"fact_count": fact_count,
|
|
141
|
+
"state": state,
|
|
142
|
+
"all_erased": bool(all_erased),
|
|
143
|
+
"evidence": evidence_json,
|
|
144
|
+
"requested_at": repr(float(requested_at)),
|
|
145
|
+
"completed_at": repr(float(completed_at)),
|
|
146
|
+
}
|
|
147
|
+
if receipt_version is not None:
|
|
148
|
+
envelope["receipt_version"] = receipt_version
|
|
149
|
+
return json.dumps(
|
|
150
|
+
envelope, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
|
151
|
+
).encode("utf-8")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def compute_erasure_hmac(
|
|
155
|
+
*,
|
|
156
|
+
erasure_id: str,
|
|
157
|
+
profile_id: str,
|
|
158
|
+
subject_type: str,
|
|
159
|
+
subject_id: str,
|
|
160
|
+
requested_by: str,
|
|
161
|
+
fact_count: int,
|
|
162
|
+
state: str,
|
|
163
|
+
all_erased: bool,
|
|
164
|
+
evidence_json: str,
|
|
165
|
+
requested_at: float,
|
|
166
|
+
completed_at: float,
|
|
167
|
+
key: bytes | None = None,
|
|
168
|
+
) -> str:
|
|
169
|
+
"""HMAC-SHA256 keyed hash for erasure receipts (v2).
|
|
170
|
+
|
|
171
|
+
``key`` is injected in tests; production code passes ``key=None`` to
|
|
172
|
+
auto-derive from the installation key.
|
|
173
|
+
"""
|
|
174
|
+
from superlocalmemory.core.transactions.manifest_key import (
|
|
175
|
+
compute_hmac,
|
|
176
|
+
derive_receipt_hmac_key,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
actual_key = key if key is not None else derive_receipt_hmac_key()
|
|
180
|
+
canonical = _erasure_canonical(
|
|
181
|
+
erasure_id=erasure_id,
|
|
182
|
+
profile_id=profile_id,
|
|
183
|
+
subject_type=subject_type,
|
|
184
|
+
subject_id=subject_id,
|
|
185
|
+
requested_by=requested_by,
|
|
186
|
+
fact_count=fact_count,
|
|
187
|
+
state=state,
|
|
188
|
+
all_erased=all_erased,
|
|
189
|
+
evidence_json=evidence_json,
|
|
190
|
+
requested_at=requested_at,
|
|
191
|
+
completed_at=completed_at,
|
|
192
|
+
receipt_version=_RECEIPT_V2,
|
|
193
|
+
)
|
|
194
|
+
return compute_hmac(actual_key, canonical)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class ErasureService:
|
|
198
|
+
def __init__(
|
|
199
|
+
self,
|
|
200
|
+
owners: Mapping[str, ProjectionOwner],
|
|
201
|
+
*,
|
|
202
|
+
ledger: ObligationLedger | None = None,
|
|
203
|
+
audit_logger: AuditLogger | None = None,
|
|
204
|
+
) -> None:
|
|
205
|
+
self._owners: dict[str, ProjectionOwner] = dict(owners)
|
|
206
|
+
self._ledger = ledger or ObligationLedger()
|
|
207
|
+
self._audit_logger = audit_logger
|
|
208
|
+
|
|
209
|
+
def erase(
|
|
210
|
+
self,
|
|
211
|
+
db: object,
|
|
212
|
+
context: OperationContext,
|
|
213
|
+
*,
|
|
214
|
+
subject_type: str,
|
|
215
|
+
subject_id: str,
|
|
216
|
+
requested_by: str = "",
|
|
217
|
+
) -> ErasureReceipt:
|
|
218
|
+
requested_at = time.time()
|
|
219
|
+
remove_result = self.remove(db, context, memory_id=None)
|
|
220
|
+
return self.finalize(
|
|
221
|
+
db, context,
|
|
222
|
+
subject_type=subject_type, subject_id=subject_id,
|
|
223
|
+
requested_by=requested_by, requested_at=requested_at,
|
|
224
|
+
remove_result=remove_result,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def remove(
|
|
228
|
+
self, db: object, context: OperationContext, *, memory_id: str | None = None,
|
|
229
|
+
) -> RemoveResult:
|
|
230
|
+
self._record_obligations(db, context)
|
|
231
|
+
status = write_tombstones_status(
|
|
232
|
+
db, context.profile_id, tuple(sorted(set(context.fact_ids))),
|
|
233
|
+
context.operation_id, time.time(), memory_id,
|
|
234
|
+
)
|
|
235
|
+
if status == TOMBSTONE_CONFLICT:
|
|
236
|
+
# Fail closed: do not delete owner projections under a provenance
|
|
237
|
+
# conflict — the resurrection guard cannot be trusted here.
|
|
238
|
+
return RemoveResult(proofs=(), spine_ok=False, tombstoned=False)
|
|
239
|
+
proofs = [self._erase_owner(db, context, name) for name in sorted(self._owners)]
|
|
240
|
+
owners_ok = bool(proofs) and all(p.erased for p in proofs)
|
|
241
|
+
# A missing tombstone table (older schema) or an empty fact set does not
|
|
242
|
+
# gate; a real write error on a modern store does.
|
|
243
|
+
tombstone_ok = status in (TOMBSTONE_WRITTEN, TOMBSTONE_ABSENT)
|
|
244
|
+
return RemoveResult(
|
|
245
|
+
proofs=tuple(proofs),
|
|
246
|
+
spine_ok=owners_ok and tombstone_ok,
|
|
247
|
+
tombstoned=(status == TOMBSTONE_WRITTEN),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def finalize(
|
|
251
|
+
self,
|
|
252
|
+
db: object,
|
|
253
|
+
context: OperationContext,
|
|
254
|
+
*,
|
|
255
|
+
subject_type: str,
|
|
256
|
+
subject_id: str,
|
|
257
|
+
requested_by: str = "",
|
|
258
|
+
requested_at: float | None = None,
|
|
259
|
+
extra_proofs: Iterable[ErasureProofRecord] = (),
|
|
260
|
+
remove_result: "RemoveResult | None" = None,
|
|
261
|
+
) -> ErasureReceipt:
|
|
262
|
+
if subject_type not in VALID_SUBJECT_TYPES:
|
|
263
|
+
raise ValueError(f"invalid subject_type: {subject_type!r}")
|
|
264
|
+
requested_at = time.time() if requested_at is None else requested_at
|
|
265
|
+
proofs: list[ErasureProofRecord] = [
|
|
266
|
+
self._prove_owner(context, name) for name in sorted(self._owners)
|
|
267
|
+
]
|
|
268
|
+
proofs.extend(extra_proofs)
|
|
269
|
+
owners_erased = bool(proofs) and all(p.erased for p in proofs)
|
|
270
|
+
# Fail closed: COMPLETE requires proven owner erasure AND, when the
|
|
271
|
+
# caller threads its remove() result, a clean tombstone step (no
|
|
272
|
+
# provenance conflict or write error on a modern store).
|
|
273
|
+
all_erased = owners_erased and (
|
|
274
|
+
remove_result.spine_ok if remove_result is not None else True
|
|
275
|
+
)
|
|
276
|
+
state = ErasureState.COMPLETE if all_erased else ErasureState.FAILED
|
|
277
|
+
evidence_json = json.dumps(
|
|
278
|
+
{"fact_ids": sorted(set(context.fact_ids)), "proofs": _proof_dicts(proofs)},
|
|
279
|
+
sort_keys=True,
|
|
280
|
+
separators=(",", ":"),
|
|
281
|
+
ensure_ascii=False,
|
|
282
|
+
)
|
|
283
|
+
fact_count = len(set(context.fact_ids))
|
|
284
|
+
completed_at = time.time()
|
|
285
|
+
receipt_version = _receipt_version_from_db(db)
|
|
286
|
+
if receipt_version >= _RECEIPT_V2:
|
|
287
|
+
audit_hash = compute_erasure_hmac(
|
|
288
|
+
erasure_id=context.operation_id,
|
|
289
|
+
profile_id=context.profile_id,
|
|
290
|
+
subject_type=subject_type,
|
|
291
|
+
subject_id=subject_id,
|
|
292
|
+
requested_by=requested_by,
|
|
293
|
+
fact_count=fact_count,
|
|
294
|
+
state=state,
|
|
295
|
+
all_erased=all_erased,
|
|
296
|
+
evidence_json=evidence_json,
|
|
297
|
+
requested_at=requested_at,
|
|
298
|
+
completed_at=completed_at,
|
|
299
|
+
)
|
|
300
|
+
else:
|
|
301
|
+
audit_hash = compute_erasure_hash(
|
|
302
|
+
erasure_id=context.operation_id,
|
|
303
|
+
profile_id=context.profile_id,
|
|
304
|
+
subject_type=subject_type,
|
|
305
|
+
subject_id=subject_id,
|
|
306
|
+
requested_by=requested_by,
|
|
307
|
+
fact_count=fact_count,
|
|
308
|
+
state=state,
|
|
309
|
+
all_erased=all_erased,
|
|
310
|
+
evidence_json=evidence_json,
|
|
311
|
+
requested_at=requested_at,
|
|
312
|
+
completed_at=completed_at,
|
|
313
|
+
)
|
|
314
|
+
persisted = self._persist(
|
|
315
|
+
db,
|
|
316
|
+
erasure_id=context.operation_id,
|
|
317
|
+
profile_id=context.profile_id,
|
|
318
|
+
subject_type=subject_type,
|
|
319
|
+
subject_id=subject_id,
|
|
320
|
+
requested_by=requested_by,
|
|
321
|
+
fact_count=fact_count,
|
|
322
|
+
state=state,
|
|
323
|
+
all_erased=all_erased,
|
|
324
|
+
evidence_json=evidence_json,
|
|
325
|
+
audit_hash=audit_hash,
|
|
326
|
+
requested_at=requested_at,
|
|
327
|
+
completed_at=completed_at,
|
|
328
|
+
fact_ids=tuple(sorted(set(context.fact_ids))),
|
|
329
|
+
)
|
|
330
|
+
self._emit_audit(
|
|
331
|
+
erasure_id=context.operation_id,
|
|
332
|
+
profile_id=context.profile_id,
|
|
333
|
+
subject_type=subject_type,
|
|
334
|
+
subject_id=subject_id,
|
|
335
|
+
requested_by=requested_by,
|
|
336
|
+
state=state,
|
|
337
|
+
audit_hash=audit_hash,
|
|
338
|
+
)
|
|
339
|
+
return ErasureReceipt(
|
|
340
|
+
erasure_id=context.operation_id,
|
|
341
|
+
profile_id=context.profile_id,
|
|
342
|
+
subject_type=subject_type,
|
|
343
|
+
subject_id=subject_id,
|
|
344
|
+
requested_by=requested_by,
|
|
345
|
+
fact_count=fact_count,
|
|
346
|
+
state=state,
|
|
347
|
+
all_erased=all_erased,
|
|
348
|
+
proofs=tuple(proofs),
|
|
349
|
+
audit_hash=audit_hash,
|
|
350
|
+
requested_at=requested_at,
|
|
351
|
+
completed_at=completed_at,
|
|
352
|
+
persisted=persisted,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def _prove_owner(
|
|
356
|
+
self, context: OperationContext, name: str,
|
|
357
|
+
) -> ErasureProofRecord:
|
|
358
|
+
owner = self._owners[name]
|
|
359
|
+
try:
|
|
360
|
+
proof = owner.prove_erased(context)
|
|
361
|
+
erased = bool(proof.erased)
|
|
362
|
+
checksum = proof.checksum or ""
|
|
363
|
+
raw_residue = dict(proof.detail).get("residue")
|
|
364
|
+
residue = (
|
|
365
|
+
tuple(str(x) for x in raw_residue)
|
|
366
|
+
if isinstance(raw_residue, list) else ()
|
|
367
|
+
)
|
|
368
|
+
except Exception as exc: # noqa: BLE001
|
|
369
|
+
erased = False
|
|
370
|
+
checksum = ""
|
|
371
|
+
residue = ()
|
|
372
|
+
logger.warning("erasure prove failed for %s: %s", name, _err(exc))
|
|
373
|
+
return ErasureProofRecord(
|
|
374
|
+
owner=name, erased=erased, checksum=checksum, residue=residue,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
def _record_obligations(self, db: object, context: OperationContext) -> None:
|
|
378
|
+
try:
|
|
379
|
+
with db.raw_connection() as conn:
|
|
380
|
+
for name in sorted(self._owners):
|
|
381
|
+
self._ledger.record(conn, context, name, ObligationKind.ERASE)
|
|
382
|
+
except Exception as exc: # noqa: BLE001
|
|
383
|
+
logger.warning("erasure obligation record skipped: %s", _err(exc))
|
|
384
|
+
|
|
385
|
+
def _write_tombstones(
|
|
386
|
+
self,
|
|
387
|
+
db: object,
|
|
388
|
+
profile_id: str,
|
|
389
|
+
fact_ids: tuple[str, ...],
|
|
390
|
+
erasure_id: str,
|
|
391
|
+
created_at: float,
|
|
392
|
+
memory_id: str | None = None,
|
|
393
|
+
) -> None:
|
|
394
|
+
write_tombstones(
|
|
395
|
+
db, profile_id, fact_ids, erasure_id, created_at, memory_id,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def _erase_owner(
|
|
399
|
+
self, db: object, context: OperationContext, name: str,
|
|
400
|
+
) -> ErasureProofRecord:
|
|
401
|
+
owner = self._owners[name]
|
|
402
|
+
residue: tuple[str, ...] = ()
|
|
403
|
+
try:
|
|
404
|
+
proof = owner.erase(context)
|
|
405
|
+
erased = bool(proof.erased)
|
|
406
|
+
checksum = proof.checksum or ""
|
|
407
|
+
detail = {"phase": "erase", **dict(proof.detail)}
|
|
408
|
+
raw_residue = detail.get("residue")
|
|
409
|
+
if isinstance(raw_residue, list):
|
|
410
|
+
residue = tuple(str(x) for x in raw_residue)
|
|
411
|
+
except Exception as exc: # noqa: BLE001
|
|
412
|
+
erased = False
|
|
413
|
+
checksum = ""
|
|
414
|
+
detail = {"phase": "erase", "error": _err(exc)}
|
|
415
|
+
try:
|
|
416
|
+
with db.raw_connection() as conn:
|
|
417
|
+
self._ledger.mark(
|
|
418
|
+
conn,
|
|
419
|
+
context.operation_id,
|
|
420
|
+
name,
|
|
421
|
+
ObligationKind.ERASE,
|
|
422
|
+
ObligationState.ERASED if erased else ObligationState.FAILED,
|
|
423
|
+
checksum=checksum or None,
|
|
424
|
+
detail=detail,
|
|
425
|
+
bump_attempts=True,
|
|
426
|
+
)
|
|
427
|
+
except Exception as exc: # noqa: BLE001
|
|
428
|
+
logger.warning("erasure obligation mark skipped for %s: %s", name, _err(exc))
|
|
429
|
+
return ErasureProofRecord(
|
|
430
|
+
owner=name, erased=erased, checksum=checksum, residue=residue,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
def _persist(
|
|
434
|
+
self,
|
|
435
|
+
db: object,
|
|
436
|
+
*,
|
|
437
|
+
erasure_id: str,
|
|
438
|
+
profile_id: str,
|
|
439
|
+
subject_type: str,
|
|
440
|
+
subject_id: str,
|
|
441
|
+
requested_by: str,
|
|
442
|
+
fact_count: int,
|
|
443
|
+
state: str,
|
|
444
|
+
all_erased: bool,
|
|
445
|
+
evidence_json: str,
|
|
446
|
+
audit_hash: str,
|
|
447
|
+
requested_at: float,
|
|
448
|
+
completed_at: float,
|
|
449
|
+
fact_ids: tuple[str, ...],
|
|
450
|
+
) -> bool:
|
|
451
|
+
try:
|
|
452
|
+
with db.raw_connection() as conn:
|
|
453
|
+
if not _table_exists(conn, "erasure_receipts"):
|
|
454
|
+
return False
|
|
455
|
+
version = _receipt_version_supported(conn)
|
|
456
|
+
if version >= _RECEIPT_V2:
|
|
457
|
+
conn.execute(
|
|
458
|
+
"INSERT INTO erasure_receipts "
|
|
459
|
+
"(erasure_id, profile_id, subject_type, subject_id, "
|
|
460
|
+
"requested_by, fact_count, state, all_erased, "
|
|
461
|
+
"owner_evidence_json, audit_hash, receipt_version, "
|
|
462
|
+
"requested_at, completed_at) "
|
|
463
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
|
464
|
+
"ON CONFLICT(erasure_id) DO UPDATE SET "
|
|
465
|
+
"state = excluded.state, "
|
|
466
|
+
"all_erased = excluded.all_erased, "
|
|
467
|
+
"owner_evidence_json = excluded.owner_evidence_json, "
|
|
468
|
+
"audit_hash = excluded.audit_hash, "
|
|
469
|
+
"receipt_version = excluded.receipt_version, "
|
|
470
|
+
"requested_at = excluded.requested_at, "
|
|
471
|
+
"completed_at = excluded.completed_at",
|
|
472
|
+
(
|
|
473
|
+
erasure_id, profile_id, subject_type, subject_id,
|
|
474
|
+
requested_by, fact_count, state,
|
|
475
|
+
1 if all_erased else 0, evidence_json, audit_hash,
|
|
476
|
+
_RECEIPT_V2, requested_at, completed_at,
|
|
477
|
+
),
|
|
478
|
+
)
|
|
479
|
+
else:
|
|
480
|
+
conn.execute(
|
|
481
|
+
"INSERT INTO erasure_receipts "
|
|
482
|
+
"(erasure_id, profile_id, subject_type, subject_id, "
|
|
483
|
+
"requested_by, fact_count, state, all_erased, "
|
|
484
|
+
"owner_evidence_json, audit_hash, requested_at, completed_at) "
|
|
485
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
|
486
|
+
"ON CONFLICT(erasure_id) DO UPDATE SET "
|
|
487
|
+
"state = excluded.state, "
|
|
488
|
+
"all_erased = excluded.all_erased, "
|
|
489
|
+
"owner_evidence_json = excluded.owner_evidence_json, "
|
|
490
|
+
"audit_hash = excluded.audit_hash, "
|
|
491
|
+
"requested_at = excluded.requested_at, "
|
|
492
|
+
"completed_at = excluded.completed_at",
|
|
493
|
+
(
|
|
494
|
+
erasure_id, profile_id, subject_type, subject_id,
|
|
495
|
+
requested_by, fact_count, state,
|
|
496
|
+
1 if all_erased else 0, evidence_json, audit_hash,
|
|
497
|
+
requested_at, completed_at,
|
|
498
|
+
),
|
|
499
|
+
)
|
|
500
|
+
conn.commit()
|
|
501
|
+
return True
|
|
502
|
+
except Exception as exc: # noqa: BLE001
|
|
503
|
+
logger.warning("erasure receipt persist skipped: %s", _err(exc))
|
|
504
|
+
return False
|
|
505
|
+
|
|
506
|
+
def _emit_audit(
|
|
507
|
+
self,
|
|
508
|
+
*,
|
|
509
|
+
erasure_id: str,
|
|
510
|
+
profile_id: str,
|
|
511
|
+
subject_type: str,
|
|
512
|
+
subject_id: str,
|
|
513
|
+
requested_by: str,
|
|
514
|
+
state: str,
|
|
515
|
+
audit_hash: str,
|
|
516
|
+
) -> None:
|
|
517
|
+
if self._audit_logger is None:
|
|
518
|
+
return
|
|
519
|
+
try:
|
|
520
|
+
self._audit_logger({
|
|
521
|
+
"erasure_id": erasure_id,
|
|
522
|
+
"profile_id": profile_id,
|
|
523
|
+
"subject_type": subject_type,
|
|
524
|
+
"subject_id": subject_id,
|
|
525
|
+
"requested_by": requested_by,
|
|
526
|
+
"state": state,
|
|
527
|
+
"audit_hash": audit_hash,
|
|
528
|
+
})
|
|
529
|
+
except Exception as exc: # noqa: BLE001
|
|
530
|
+
logger.warning("erasure audit-chain emit skipped: %s", _err(exc))
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def fetch_receipt(
|
|
534
|
+
conn: object, erasure_id: str, *, profile_id: str | None = None,
|
|
535
|
+
) -> ErasureReceipt | None:
|
|
536
|
+
predicate = "erasure_id = ?"
|
|
537
|
+
params: list[object] = [erasure_id]
|
|
538
|
+
if profile_id is not None:
|
|
539
|
+
predicate += " AND profile_id = ?"
|
|
540
|
+
params.append(profile_id)
|
|
541
|
+
row = conn.execute(
|
|
542
|
+
"SELECT erasure_id, profile_id, subject_type, subject_id, requested_by, "
|
|
543
|
+
"fact_count, state, all_erased, owner_evidence_json, audit_hash, "
|
|
544
|
+
f"requested_at, completed_at FROM erasure_receipts WHERE {predicate}",
|
|
545
|
+
tuple(params),
|
|
546
|
+
).fetchone()
|
|
547
|
+
if row is None:
|
|
548
|
+
return None
|
|
549
|
+
return ErasureReceipt(
|
|
550
|
+
erasure_id=row[0],
|
|
551
|
+
profile_id=row[1],
|
|
552
|
+
subject_type=row[2],
|
|
553
|
+
subject_id=row[3],
|
|
554
|
+
requested_by=row[4],
|
|
555
|
+
fact_count=int(row[5]),
|
|
556
|
+
state=row[6],
|
|
557
|
+
all_erased=bool(row[7]),
|
|
558
|
+
proofs=_proofs_from_json(row[8]),
|
|
559
|
+
audit_hash=row[9],
|
|
560
|
+
requested_at=float(row[10]),
|
|
561
|
+
completed_at=float(row[11]),
|
|
562
|
+
persisted=True,
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def verify_receipt(
|
|
567
|
+
conn: object, erasure_id: str, *, profile_id: str | None = None,
|
|
568
|
+
) -> bool:
|
|
569
|
+
"""Verify the audit_hash of an erasure receipt.
|
|
570
|
+
|
|
571
|
+
Uses HMAC (v2) when the ``receipt_version`` column is present and
|
|
572
|
+
the row has version >= 2, otherwise falls back to unkeyed SHA-256 (v1).
|
|
573
|
+
"""
|
|
574
|
+
predicate = "erasure_id = ?"
|
|
575
|
+
params: list[object] = [erasure_id]
|
|
576
|
+
if profile_id is not None:
|
|
577
|
+
predicate += " AND profile_id = ?"
|
|
578
|
+
params.append(profile_id)
|
|
579
|
+
|
|
580
|
+
db_version = _receipt_version_supported(conn)
|
|
581
|
+
row_version = _receipt_row_version(conn, erasure_id)
|
|
582
|
+
|
|
583
|
+
row = conn.execute(
|
|
584
|
+
"SELECT erasure_id, profile_id, subject_type, subject_id, requested_by, "
|
|
585
|
+
"fact_count, state, all_erased, owner_evidence_json, audit_hash, "
|
|
586
|
+
f"requested_at, completed_at FROM erasure_receipts WHERE {predicate}",
|
|
587
|
+
tuple(params),
|
|
588
|
+
).fetchone()
|
|
589
|
+
if row is None:
|
|
590
|
+
return False
|
|
591
|
+
|
|
592
|
+
kwargs = dict(
|
|
593
|
+
erasure_id=row[0],
|
|
594
|
+
profile_id=row[1],
|
|
595
|
+
subject_type=row[2],
|
|
596
|
+
subject_id=row[3],
|
|
597
|
+
requested_by=row[4],
|
|
598
|
+
fact_count=int(row[5]),
|
|
599
|
+
state=row[6],
|
|
600
|
+
all_erased=bool(row[7]),
|
|
601
|
+
evidence_json=row[8],
|
|
602
|
+
requested_at=float(row[10]),
|
|
603
|
+
completed_at=float(row[11]),
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
if db_version >= _RECEIPT_V2:
|
|
607
|
+
# On M037-capable DBs NEVER accept the unkeyed v1 SHA path — a
|
|
608
|
+
# version-downgrade forgery rewrites receipt_version=1 with a valid
|
|
609
|
+
# SHA of mutated content. Any v1 row here is rejected; legitimate
|
|
610
|
+
# old rows should be re-sealed before use.
|
|
611
|
+
if row_version < _RECEIPT_V2:
|
|
612
|
+
return False
|
|
613
|
+
from superlocalmemory.core.transactions.manifest_key import (
|
|
614
|
+
derive_receipt_hmac_key,
|
|
615
|
+
verify_hmac,
|
|
616
|
+
)
|
|
617
|
+
canonical = _erasure_canonical(**kwargs, receipt_version=_RECEIPT_V2)
|
|
618
|
+
return verify_hmac(row[9], derive_receipt_hmac_key(), canonical)
|
|
619
|
+
|
|
620
|
+
# v1 path (M037 absent) — use constant-time comparison to prevent
|
|
621
|
+
# timing oracles on old SHA256 hex digests.
|
|
622
|
+
recomputed = compute_erasure_hash(**kwargs)
|
|
623
|
+
stored = row[9] or ""
|
|
624
|
+
return _hmac_mod.compare_digest(
|
|
625
|
+
recomputed.encode("utf-8"), stored.encode("utf-8")
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
# Tombstone write outcomes. WRITTEN/ABSENT are non-failures (ABSENT = the
|
|
630
|
+
# table does not exist on an older schema, or there were no fact ids);
|
|
631
|
+
# CONFLICT and ERROR must fail the erasure closed on a modern store.
|
|
632
|
+
TOMBSTONE_WRITTEN = "written"
|
|
633
|
+
TOMBSTONE_ABSENT = "absent"
|
|
634
|
+
TOMBSTONE_CONFLICT = "conflict"
|
|
635
|
+
TOMBSTONE_ERROR = "error"
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def write_tombstones_status(
|
|
639
|
+
db: object,
|
|
640
|
+
profile_id: str,
|
|
641
|
+
fact_ids: tuple[str, ...],
|
|
642
|
+
erasure_id: str,
|
|
643
|
+
created_at: float,
|
|
644
|
+
memory_id: str | None = None,
|
|
645
|
+
) -> str:
|
|
646
|
+
"""Write projection tombstones and report a precise outcome code.
|
|
647
|
+
|
|
648
|
+
Returns one of TOMBSTONE_WRITTEN / TOMBSTONE_ABSENT / TOMBSTONE_CONFLICT /
|
|
649
|
+
TOMBSTONE_ERROR so callers can fail an erasure closed on a real failure
|
|
650
|
+
while still tolerating an older schema without the tombstone table.
|
|
651
|
+
"""
|
|
652
|
+
if not fact_ids:
|
|
653
|
+
return TOMBSTONE_ABSENT
|
|
654
|
+
try:
|
|
655
|
+
with db.raw_connection() as conn:
|
|
656
|
+
if not _table_exists(conn, "projection_tombstones"):
|
|
657
|
+
return TOMBSTONE_ABSENT
|
|
658
|
+
for fact_id in fact_ids:
|
|
659
|
+
conn.execute(
|
|
660
|
+
"INSERT INTO projection_tombstones "
|
|
661
|
+
"(profile_id, fact_id, erasure_id, memory_id, created_at) "
|
|
662
|
+
"VALUES (?, ?, ?, ?, ?) "
|
|
663
|
+
"ON CONFLICT(profile_id, fact_id) DO UPDATE SET "
|
|
664
|
+
"memory_id = COALESCE(projection_tombstones.memory_id, excluded.memory_id)",
|
|
665
|
+
(profile_id, fact_id, erasure_id, memory_id, created_at),
|
|
666
|
+
)
|
|
667
|
+
stored = conn.execute(
|
|
668
|
+
"SELECT memory_id FROM projection_tombstones "
|
|
669
|
+
"WHERE profile_id = ? AND fact_id = ?",
|
|
670
|
+
(profile_id, fact_id),
|
|
671
|
+
).fetchone()
|
|
672
|
+
if (
|
|
673
|
+
stored is not None
|
|
674
|
+
and memory_id is not None
|
|
675
|
+
and stored[0] is not None
|
|
676
|
+
and stored[0] != memory_id
|
|
677
|
+
):
|
|
678
|
+
logger.error(
|
|
679
|
+
"tombstone provenance conflict for %s: stored=%r != passed=%r; "
|
|
680
|
+
"failing closed",
|
|
681
|
+
fact_id[:16], stored[0], memory_id,
|
|
682
|
+
)
|
|
683
|
+
try:
|
|
684
|
+
conn.rollback()
|
|
685
|
+
except Exception: # noqa: BLE001
|
|
686
|
+
pass
|
|
687
|
+
return TOMBSTONE_CONFLICT
|
|
688
|
+
conn.commit()
|
|
689
|
+
return TOMBSTONE_WRITTEN
|
|
690
|
+
except Exception as exc: # noqa: BLE001
|
|
691
|
+
logger.warning("erasure tombstone write skipped: %s", _err(exc))
|
|
692
|
+
return TOMBSTONE_ERROR
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def write_tombstones(
|
|
696
|
+
db: object,
|
|
697
|
+
profile_id: str,
|
|
698
|
+
fact_ids: tuple[str, ...],
|
|
699
|
+
erasure_id: str,
|
|
700
|
+
created_at: float,
|
|
701
|
+
memory_id: str | None = None,
|
|
702
|
+
) -> bool:
|
|
703
|
+
"""Backward-compatible boolean wrapper: True only on a clean write."""
|
|
704
|
+
return (
|
|
705
|
+
write_tombstones_status(
|
|
706
|
+
db, profile_id, fact_ids, erasure_id, created_at, memory_id
|
|
707
|
+
)
|
|
708
|
+
== TOMBSTONE_WRITTEN
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def is_tombstoned(conn: object, profile_id: str, fact_id: str) -> bool:
|
|
713
|
+
row = conn.execute(
|
|
714
|
+
"SELECT 1 FROM projection_tombstones WHERE profile_id = ? AND fact_id = ?",
|
|
715
|
+
(profile_id, fact_id),
|
|
716
|
+
).fetchone()
|
|
717
|
+
return row is not None
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def tombstone_memory_id(db: object, profile_id: str, fact_id: str) -> str | None:
|
|
721
|
+
try:
|
|
722
|
+
with db.raw_connection() as conn:
|
|
723
|
+
if not _table_exists(conn, "projection_tombstones"):
|
|
724
|
+
return None
|
|
725
|
+
row = conn.execute(
|
|
726
|
+
"SELECT memory_id FROM projection_tombstones "
|
|
727
|
+
"WHERE profile_id = ? AND fact_id = ?",
|
|
728
|
+
(profile_id, fact_id),
|
|
729
|
+
).fetchone()
|
|
730
|
+
return row[0] if row and row[0] else None
|
|
731
|
+
except Exception: # noqa: BLE001
|
|
732
|
+
return None
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _proofs_from_json(payload: str) -> tuple[ErasureProofRecord, ...]:
|
|
736
|
+
try:
|
|
737
|
+
parsed = json.loads(payload)
|
|
738
|
+
except (TypeError, json.JSONDecodeError):
|
|
739
|
+
return ()
|
|
740
|
+
entries = parsed.get("proofs") if isinstance(parsed, dict) else None
|
|
741
|
+
if not isinstance(entries, list):
|
|
742
|
+
return ()
|
|
743
|
+
records: list[ErasureProofRecord] = []
|
|
744
|
+
for entry in entries:
|
|
745
|
+
if not isinstance(entry, dict):
|
|
746
|
+
continue
|
|
747
|
+
raw_residue = entry.get("residue")
|
|
748
|
+
residue = (
|
|
749
|
+
tuple(str(x) for x in raw_residue)
|
|
750
|
+
if isinstance(raw_residue, list) else ()
|
|
751
|
+
)
|
|
752
|
+
try:
|
|
753
|
+
records.append(ErasureProofRecord(
|
|
754
|
+
owner=str(entry["owner"]),
|
|
755
|
+
erased=bool(entry["erased"]),
|
|
756
|
+
checksum=str(entry["checksum"]),
|
|
757
|
+
residue=residue,
|
|
758
|
+
))
|
|
759
|
+
except KeyError:
|
|
760
|
+
continue
|
|
761
|
+
return tuple(records)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _table_exists(conn: object, name: str) -> bool:
|
|
765
|
+
return conn.execute(
|
|
766
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
|
767
|
+
(name,),
|
|
768
|
+
).fetchone() is not None
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _receipt_version_supported(conn: object) -> int:
|
|
772
|
+
"""Return the highest receipt version this DB supports."""
|
|
773
|
+
try:
|
|
774
|
+
cols = {row[1] for row in conn.execute("PRAGMA table_info(erasure_receipts)").fetchall()}
|
|
775
|
+
return _RECEIPT_V2 if "receipt_version" in cols else _RECEIPT_V1
|
|
776
|
+
except Exception: # noqa: BLE001
|
|
777
|
+
# Fail-closed: PRAGMA failure must not silently route verification through
|
|
778
|
+
# the unkeyed-SHA v1 path — same downgrade-forgery risk as manifest probe.
|
|
779
|
+
return _RECEIPT_V2
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _receipt_version_from_db(db: object) -> int:
|
|
783
|
+
"""Return the highest receipt version supported by the DB wrapper."""
|
|
784
|
+
try:
|
|
785
|
+
with db.raw_connection() as conn:
|
|
786
|
+
return _receipt_version_supported(conn)
|
|
787
|
+
except Exception: # noqa: BLE001
|
|
788
|
+
return _RECEIPT_V1
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _receipt_row_version(conn: object, erasure_id: str) -> int:
|
|
792
|
+
"""Read the receipt_version for an existing row; fall back to V1."""
|
|
793
|
+
try:
|
|
794
|
+
cols = {row[1] for row in conn.execute("PRAGMA table_info(erasure_receipts)").fetchall()}
|
|
795
|
+
if "receipt_version" not in cols:
|
|
796
|
+
return _RECEIPT_V1
|
|
797
|
+
row = conn.execute(
|
|
798
|
+
"SELECT receipt_version FROM erasure_receipts WHERE erasure_id = ?",
|
|
799
|
+
(erasure_id,),
|
|
800
|
+
).fetchone()
|
|
801
|
+
return int(row[0]) if row and row[0] is not None else _RECEIPT_V1
|
|
802
|
+
except Exception: # noqa: BLE001
|
|
803
|
+
return _RECEIPT_V1
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _err(exc: BaseException) -> str:
|
|
807
|
+
return f"{type(exc).__name__}: {exc}"[:500]
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
__all__ = [
|
|
811
|
+
"ErasureProofRecord",
|
|
812
|
+
"ErasureReceipt",
|
|
813
|
+
"ErasureService",
|
|
814
|
+
"ErasureState",
|
|
815
|
+
"RemoveResult",
|
|
816
|
+
"MAX_ERASE_ATTEMPTS",
|
|
817
|
+
"VALID_SUBJECT_TYPES",
|
|
818
|
+
"compute_erasure_hash",
|
|
819
|
+
"compute_erasure_hmac",
|
|
820
|
+
"fetch_receipt",
|
|
821
|
+
"is_tombstoned",
|
|
822
|
+
"tombstone_memory_id",
|
|
823
|
+
"verify_receipt",
|
|
824
|
+
"write_tombstones",
|
|
825
|
+
]
|