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.
Files changed (212) hide show
  1. package/ATTRIBUTION.md +4 -4
  2. package/CHANGELOG.md +113 -121
  3. package/README.md +65 -63
  4. package/docs/pi-dev-integration.md +1 -1
  5. package/package.json +6 -1
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  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 +1 -1
  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 +1 -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 +1 -1
  26. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-governance/SKILL.md +248 -0
  29. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-loop/SKILL.md +99 -0
  31. package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
  32. package/plugin-src/skills/slm-profile/SKILL.md +148 -0
  33. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-scope/SKILL.md +176 -0
  36. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  37. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  38. package/pyproject.toml +11 -4
  39. package/src/superlocalmemory/__init__.py +1 -1
  40. package/src/superlocalmemory/cli/commands.py +125 -11
  41. package/src/superlocalmemory/cli/daemon.py +5 -1
  42. package/src/superlocalmemory/cli/main.py +35 -2
  43. package/src/superlocalmemory/cli/ops_cmd.py +281 -0
  44. package/src/superlocalmemory/cli/setup_wizard.py +1 -1
  45. package/src/superlocalmemory/compliance/audit.py +65 -0
  46. package/src/superlocalmemory/compliance/eu_ai_act.py +27 -57
  47. package/src/superlocalmemory/compliance/gdpr.py +416 -20
  48. package/src/superlocalmemory/compliance/retention.py +74 -22
  49. package/src/superlocalmemory/compliance/scheduler.py +78 -9
  50. package/src/superlocalmemory/core/actor_context.py +166 -0
  51. package/src/superlocalmemory/core/admission.py +549 -0
  52. package/src/superlocalmemory/core/backend_orchestrator.py +23 -10
  53. package/src/superlocalmemory/core/config.py +202 -24
  54. package/src/superlocalmemory/core/consolidation_engine.py +13 -13
  55. package/src/superlocalmemory/core/context_cache.py +28 -0
  56. package/src/superlocalmemory/core/embeddings.py +64 -2
  57. package/src/superlocalmemory/core/engine.py +7 -2
  58. package/src/superlocalmemory/core/engine_ingestion.py +65 -3
  59. package/src/superlocalmemory/core/engine_wiring.py +36 -9
  60. package/src/superlocalmemory/core/ingest_policy.py +38 -0
  61. package/src/superlocalmemory/core/maintenance.py +255 -0
  62. package/src/superlocalmemory/core/modes.py +40 -13
  63. package/src/superlocalmemory/core/mutations.py +437 -44
  64. package/src/superlocalmemory/core/operation_policy.py +92 -0
  65. package/src/superlocalmemory/core/operation_policy_registry.py +542 -0
  66. package/src/superlocalmemory/core/operation_request.py +127 -0
  67. package/src/superlocalmemory/core/ops_remediation.py +542 -0
  68. package/src/superlocalmemory/core/recall_pipeline.py +7 -0
  69. package/src/superlocalmemory/core/remember_runtime.py +202 -4
  70. package/src/superlocalmemory/core/remote_mode.py +20 -5
  71. package/src/superlocalmemory/core/store_pipeline.py +150 -0
  72. package/src/superlocalmemory/core/topic_signature.py +19 -4
  73. package/src/superlocalmemory/core/transactions/__init__.py +78 -0
  74. package/src/superlocalmemory/core/transactions/concrete_owners.py +597 -0
  75. package/src/superlocalmemory/core/transactions/erasure.py +825 -0
  76. package/src/superlocalmemory/core/transactions/manifest.py +255 -0
  77. package/src/superlocalmemory/core/transactions/manifest_key.py +155 -0
  78. package/src/superlocalmemory/core/transactions/obligations.py +272 -0
  79. package/src/superlocalmemory/core/transactions/owners.py +114 -0
  80. package/src/superlocalmemory/core/transactions/reconciler.py +285 -0
  81. package/src/superlocalmemory/core/transactions/service.py +330 -0
  82. package/src/superlocalmemory/core/worker_pool.py +33 -5
  83. package/src/superlocalmemory/encoding/cognitive_consolidator.py +70 -28
  84. package/src/superlocalmemory/encoding/emotional.py +75 -14
  85. package/src/superlocalmemory/encoding/scene_builder.py +115 -13
  86. package/src/superlocalmemory/encoding/temporal_parser.py +4 -0
  87. package/src/superlocalmemory/evolution/blind_verifier.py +11 -4
  88. package/src/superlocalmemory/evolution/evolution_store.py +244 -4
  89. package/src/superlocalmemory/evolution/llm_dispatch.py +40 -0
  90. package/src/superlocalmemory/evolution/model_selection.py +18 -3
  91. package/src/superlocalmemory/evolution/mutation_generator.py +3 -0
  92. package/src/superlocalmemory/evolution/skill_activator.py +270 -0
  93. package/src/superlocalmemory/evolution/skill_evolver.py +281 -59
  94. package/src/superlocalmemory/evolution/types.py +30 -8
  95. package/src/superlocalmemory/graph/cozo_backend.py +17 -9
  96. package/src/superlocalmemory/hooks/auto_invoker.py +2 -1
  97. package/src/superlocalmemory/hooks/auto_recall.py +64 -30
  98. package/src/superlocalmemory/hooks/codex_assets.py +14 -1
  99. package/src/superlocalmemory/infra/backup.py +434 -7
  100. package/src/superlocalmemory/infra/process_reaper.py +18 -0
  101. package/src/superlocalmemory/infra/self_heal.py +401 -0
  102. package/src/superlocalmemory/learning/feedback.py +52 -9
  103. package/src/superlocalmemory/loops/engine.py +10 -0
  104. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  105. package/src/superlocalmemory/mcp/http_transport.py +30 -331
  106. package/src/superlocalmemory/mcp/profiles.py +5 -0
  107. package/src/superlocalmemory/mcp/resources.py +8 -0
  108. package/src/superlocalmemory/mcp/server.py +51 -4
  109. package/src/superlocalmemory/mcp/shared.py +19 -0
  110. package/src/superlocalmemory/mcp/tools_active.py +25 -4
  111. package/src/superlocalmemory/mcp/tools_code_graph.py +26 -18
  112. package/src/superlocalmemory/mcp/tools_context.py +50 -8
  113. package/src/superlocalmemory/mcp/tools_core.py +69 -21
  114. package/src/superlocalmemory/mcp/tools_evolution.py +9 -2
  115. package/src/superlocalmemory/mcp/tools_learning.py +21 -10
  116. package/src/superlocalmemory/mcp/tools_loops.py +29 -18
  117. package/src/superlocalmemory/mcp/tools_mesh.py +8 -0
  118. package/src/superlocalmemory/mcp/tools_ops.py +115 -0
  119. package/src/superlocalmemory/mcp/tools_optimize.py +4 -0
  120. package/src/superlocalmemory/mcp/tools_v28.py +10 -3
  121. package/src/superlocalmemory/mcp/tools_v3.py +34 -14
  122. package/src/superlocalmemory/mcp/tools_v33.py +18 -33
  123. package/src/superlocalmemory/mesh/broker.py +124 -46
  124. package/src/superlocalmemory/mesh/broker_security.py +470 -0
  125. package/src/superlocalmemory/mesh/discovery.py +365 -0
  126. package/src/superlocalmemory/mesh/lock_protocol.py +313 -0
  127. package/src/superlocalmemory/mesh/node_identity.py +97 -0
  128. package/src/superlocalmemory/mesh/outbox_remote.py +429 -0
  129. package/src/superlocalmemory/mesh/remote_sync.py +511 -28
  130. package/src/superlocalmemory/mesh/state_sync.py +286 -0
  131. package/src/superlocalmemory/optimize/config/store.py +45 -0
  132. package/src/superlocalmemory/parameterization/cross_project.py +12 -0
  133. package/src/superlocalmemory/parameterization/prompt_injector.py +13 -11
  134. package/src/superlocalmemory/parameterization/prompt_lifecycle.py +8 -2
  135. package/src/superlocalmemory/parameterization/workflow_miner.py +17 -0
  136. package/src/superlocalmemory/retrieval/ann_index.py +5 -0
  137. package/src/superlocalmemory/retrieval/bm25_channel.py +49 -2
  138. package/src/superlocalmemory/retrieval/engine.py +19 -4
  139. package/src/superlocalmemory/retrieval/fusion.py +4 -1
  140. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -3
  141. package/src/superlocalmemory/retrieval/remote_reranker.py +47 -22
  142. package/src/superlocalmemory/retrieval/reranker.py +32 -1
  143. package/src/superlocalmemory/retrieval/temporal_channel.py +16 -3
  144. package/src/superlocalmemory/retrieval/temporal_utils.py +107 -0
  145. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +155 -42
  146. package/src/superlocalmemory/retrieval/vector_store.py +214 -8
  147. package/src/superlocalmemory/server/api.py +5 -5
  148. package/src/superlocalmemory/server/egress_policy.py +258 -0
  149. package/src/superlocalmemory/server/rbac_enforce.py +32 -0
  150. package/src/superlocalmemory/server/route_mutations.py +20 -0
  151. package/src/superlocalmemory/server/routes/compliance.py +153 -7
  152. package/src/superlocalmemory/server/routes/data_io.py +43 -2
  153. package/src/superlocalmemory/server/routes/events.py +15 -0
  154. package/src/superlocalmemory/server/routes/memories.py +56 -3
  155. package/src/superlocalmemory/server/routes/mesh.py +82 -1
  156. package/src/superlocalmemory/server/routes/mesh_lock.py +54 -0
  157. package/src/superlocalmemory/server/routes/mesh_state.py +63 -0
  158. package/src/superlocalmemory/server/routes/v3_api.py +50 -27
  159. package/src/superlocalmemory/server/routes/ws.py +86 -0
  160. package/src/superlocalmemory/server/ui.py +6 -6
  161. package/src/superlocalmemory/server/unified_daemon.py +942 -119
  162. package/src/superlocalmemory/storage/_migration_internals.py +568 -0
  163. package/src/superlocalmemory/storage/_schema_version.py +110 -0
  164. package/src/superlocalmemory/storage/database.py +329 -24
  165. package/src/superlocalmemory/storage/embedding_migrator.py +246 -51
  166. package/src/superlocalmemory/storage/erasure_fence.py +45 -0
  167. package/src/superlocalmemory/storage/generation_fence.py +63 -0
  168. package/src/superlocalmemory/storage/migration_runner.py +140 -417
  169. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +40 -0
  170. package/src/superlocalmemory/storage/migrations/M033_projection_transactions.py +148 -0
  171. package/src/superlocalmemory/storage/migrations/M034_obligation_integrity.py +58 -0
  172. package/src/superlocalmemory/storage/migrations/M035_erasure_receipts.py +113 -0
  173. package/src/superlocalmemory/storage/migrations/M036_vector_row_map.py +107 -0
  174. package/src/superlocalmemory/storage/migrations/M037_manifest_hmac_version.py +162 -0
  175. package/src/superlocalmemory/storage/migrations/{M033_learning_feedback_channel.py → M038_learning_feedback_channel.py} +3 -3
  176. package/src/superlocalmemory/storage/migrations/M039_scene_fact_members.py +137 -0
  177. package/src/superlocalmemory/storage/migrations/__init__.py +4 -2
  178. package/src/superlocalmemory/storage/schema.py +67 -0
  179. package/src/superlocalmemory/storage/write_coordinator.py +125 -0
  180. package/src/superlocalmemory/trust/scorer.py +28 -4
  181. package/src/superlocalmemory/ui/index.html +14 -3
  182. package/src/superlocalmemory/ui/js/auto-settings.js +12 -1
  183. package/src/superlocalmemory/ui/js/brain.js +6 -4
  184. package/src/superlocalmemory/ui/js/compliance.js +66 -12
  185. package/src/superlocalmemory/ui/js/dashboard.js +13 -3
  186. package/src/superlocalmemory/ui/js/feedback.js +8 -2
  187. package/src/superlocalmemory/ui/js/lifecycle.js +7 -1
  188. package/src/superlocalmemory/ui/js/modal.js +272 -5
  189. package/src/superlocalmemory/ui/js/od-backup.js +9 -2
  190. package/src/superlocalmemory/ui/js/od-compliance-ext.js +301 -0
  191. package/src/superlocalmemory/ui/js/od-operations.js +154 -23
  192. package/src/superlocalmemory/ui/js/od-ops-health.js +417 -0
  193. package/src/superlocalmemory/ui/js/od-optimize.js +35 -21
  194. package/src/superlocalmemory/ui/js/od-team.js +9 -2
  195. package/src/superlocalmemory/ui/js/optimize.js +13 -16
  196. package/src/superlocalmemory/ui/js/profiles.js +7 -3
  197. package/src/superlocalmemory/ui/js/settings.js +7 -1
  198. package/src/superlocalmemory/vector/lancedb_backend.py +19 -9
  199. package/src/superlocalmemory/attribution/mathematical_dna.py +0 -235
  200. package/src/superlocalmemory/cli/post_install.py +0 -114
  201. package/src/superlocalmemory/core/clock_monitor.py +0 -45
  202. package/src/superlocalmemory/core/db_pool.py +0 -80
  203. package/src/superlocalmemory/core/error_catalog.py +0 -113
  204. package/src/superlocalmemory/core/loop_watchdog.py +0 -56
  205. package/src/superlocalmemory/core/priority_queue.py +0 -61
  206. package/src/superlocalmemory/core/pruning_engine.py +0 -216
  207. package/src/superlocalmemory/core/queue_dispatcher.py +0 -73
  208. package/src/superlocalmemory/core/slmignore.py +0 -125
  209. package/src/superlocalmemory/infra/heartbeat_monitor.py +0 -140
  210. package/src/superlocalmemory/infra/webhook_dispatcher.py +0 -247
  211. package/src/superlocalmemory/learning/quantization_scheduler.py +0 -320
  212. package/src/superlocalmemory/storage/access_control.py +0 -182
@@ -0,0 +1,97 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V4 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Stable per-node identity for the SLM mesh distributed protocol (3c-0).
6
+
7
+ Each daemon owns a durable ``node_id`` (uuid4 hex) persisted once in the mesh
8
+ SQLite DB (single-row ``mesh_node_identity`` table). It is the deterministic
9
+ tie-breaker for the distributed protocol layer:
10
+
11
+ * **LWW state convergence** — when two nodes hold the same key at the same
12
+ ``revision``, the winner is the one with the higher ``node_id`` (a total
13
+ order → every node converges on the same value).
14
+ * **Distributed lock ordering** — the effective lock holder is the one with the
15
+ higher ``(fencing_token, node_id)``; the fence guarantees single-writer
16
+ safety downstream even during a brief split.
17
+
18
+ Design:
19
+ * Persisted → stable across restarts (a restart must NOT change the tie-break).
20
+ * ``INSERT OR IGNORE`` + re-read → two processes racing first-creation converge
21
+ on ONE id.
22
+ * **Fail-soft**: any DB error returns a process-stable fallback
23
+ (``hostname-pid``) so callers never crash; the mesh degrades to node-local
24
+ behavior rather than breaking.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ import os
31
+ import socket
32
+ import sqlite3
33
+ import time
34
+ import uuid
35
+
36
+ logger = logging.getLogger("superlocalmemory.mesh.node_identity")
37
+
38
+ _CREATE_SQL = (
39
+ "CREATE TABLE IF NOT EXISTS mesh_node_identity ("
40
+ " id INTEGER PRIMARY KEY CHECK (id = 1),"
41
+ " node_id TEXT NOT NULL,"
42
+ " created_at REAL NOT NULL)"
43
+ )
44
+
45
+ # Process-stable fallback cache (only used when the DB is unavailable).
46
+ _fallback_cache: dict[str, str] = {}
47
+
48
+
49
+ def _fallback(db_path: str) -> str:
50
+ """Return a process-stable, non-persisted id for the fail-soft path."""
51
+ value = _fallback_cache.get(db_path)
52
+ if value is None:
53
+ value = f"{socket.gethostname()}-{os.getpid()}"
54
+ _fallback_cache[db_path] = value
55
+ return value
56
+
57
+
58
+ def get_node_id(db_path: str) -> str:
59
+ """Return this node's stable id, creating it once if absent.
60
+
61
+ Args:
62
+ db_path: Path to the mesh SQLite DB (same file the broker uses).
63
+
64
+ Returns:
65
+ A stable hex node id (persisted), or a process-stable ``hostname-pid``
66
+ fallback if the DB cannot be read/written.
67
+ """
68
+ try:
69
+ conn = sqlite3.connect(db_path, timeout=5.0)
70
+ try:
71
+ conn.execute(_CREATE_SQL)
72
+ row = conn.execute(
73
+ "SELECT node_id FROM mesh_node_identity WHERE id = 1"
74
+ ).fetchone()
75
+ if row is not None:
76
+ return row[0]
77
+ new_id = uuid.uuid4().hex
78
+ # INSERT OR IGNORE lets a concurrent first-creator win; we then
79
+ # re-read so every racer returns the SAME persisted id.
80
+ conn.execute(
81
+ "INSERT OR IGNORE INTO mesh_node_identity (id, node_id, created_at)"
82
+ " VALUES (1, ?, ?)",
83
+ (new_id, time.time()),
84
+ )
85
+ conn.commit()
86
+ row = conn.execute(
87
+ "SELECT node_id FROM mesh_node_identity WHERE id = 1"
88
+ ).fetchone()
89
+ return row[0] if row is not None else new_id
90
+ finally:
91
+ conn.close()
92
+ except sqlite3.Error as exc:
93
+ logger.error(
94
+ "get_node_id: DB error at %s — using process fallback: %s",
95
+ db_path, exc,
96
+ )
97
+ return _fallback(db_path)
@@ -0,0 +1,429 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V4 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Durable remote outbox for SLM mesh (3b-1).
6
+
7
+ Persists unsent remote mesh messages in the shared SQLite DB so they
8
+ survive peer downtime and process restarts. A background drain in
9
+ RemoteSyncClient._sync_loop re-attempts delivery with exponential
10
+ back-off + jitter.
11
+
12
+ Design choices documented in class docstring.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ import math
20
+ import random
21
+ import sqlite3
22
+ import time
23
+ from typing import Any
24
+
25
+ logger = logging.getLogger("superlocalmemory.mesh.outbox_remote")
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Constants — aligned with broker.py values
29
+ # ---------------------------------------------------------------------------
30
+ #: 48h TTL matches broker MESSAGE_TTL_HOURS so remote dead-letter horizon
31
+ #: is consistent with the local (broker-side) message lifetime.
32
+ _TTL_SECONDS: int = 48 * 3600
33
+
34
+ #: Per-peer cap matches broker MAX_QUEUED_PER_TARGET = 50.
35
+ _CAP_PER_PEER: int = 50
36
+
37
+ #: Maximum rows returned by due() per cycle. A wall-clock budget in the drain
38
+ #: loop (remote_sync._DRAIN_BUDGET_SECONDS) is the primary guard against a slow
39
+ #: peer monopolizing the sync thread; this is a secondary belt.
40
+ _BATCH_LIMIT: int = 10
41
+
42
+ #: Header keys never persisted to disk (audit P0). Callers pass headers=None,
43
+ #: but enqueue() also scrubs these defensively so a future caller cannot leak
44
+ #: a bearer token / HMAC signature into the outbox table. Matched lowercase.
45
+ _SENSITIVE_HEADER_KEYS: frozenset[str] = frozenset({
46
+ "authorization",
47
+ "x-mesh-sig",
48
+ "x-mesh-nonce",
49
+ "x-mesh-ts",
50
+ "cookie",
51
+ "x-api-key",
52
+ "x-install-token",
53
+ })
54
+
55
+ #: Hard dead-letter threshold. After this many attempts the row is deleted.
56
+ _MAX_RETRIES: int = 12
57
+
58
+ #: Backoff formula: min(BASE * 2^retry, CAP) * jitter_factor.
59
+ _BACKOFF_BASE: float = 5.0
60
+ _BACKOFF_CAP: float = 300.0 # 5 minutes
61
+
62
+ #: ±25% jitter fraction applied to the raw backoff to prevent thundering-herd
63
+ #: when many stalled sends all become due simultaneously after a peer restart.
64
+ _JITTER_FRACTION: float = 0.25
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # DDL
68
+ # ---------------------------------------------------------------------------
69
+ _CREATE_TABLE_SQL = """
70
+ CREATE TABLE IF NOT EXISTS mesh_outbox_remote (
71
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
72
+ peer_url TEXT NOT NULL,
73
+ to_peer TEXT NOT NULL,
74
+ payload TEXT NOT NULL,
75
+ headers TEXT,
76
+ retry_count INTEGER NOT NULL DEFAULT 0,
77
+ next_retry_at REAL NOT NULL,
78
+ created_at REAL NOT NULL,
79
+ expires_at REAL NOT NULL
80
+ );
81
+ """
82
+
83
+ _CREATE_INDEX_SQL = """
84
+ CREATE INDEX IF NOT EXISTS idx_outbox_remote_next_retry
85
+ ON mesh_outbox_remote (next_retry_at);
86
+ """
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Internal helpers
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ def _backoff(retry_count: int) -> float:
94
+ """Exponential backoff with ±25% jitter.
95
+
96
+ Formula: min(BASE * 2^retry_count, CAP) × (1 + U[-0.25, +0.25])
97
+ Floor at 1.0s so a jitter-deflated value stays positive.
98
+
99
+ The jitter prevents the thundering-herd problem: when a peer restarts
100
+ after downtime, all enqueued rows that became due at roughly the same
101
+ time would otherwise pile onto the peer simultaneously.
102
+ """
103
+ raw = min(_BACKOFF_BASE * math.pow(2, retry_count), _BACKOFF_CAP)
104
+ jitter = raw * _JITTER_FRACTION * (2.0 * random.random() - 1.0)
105
+ return max(1.0, raw + jitter)
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Public class
110
+ # ---------------------------------------------------------------------------
111
+
112
+
113
+ class RemoteOutbox:
114
+ """Durable store for unsent remote mesh messages (3b-1).
115
+
116
+ Persists messages into the shared SQLite DB (same file as the mesh
117
+ broker) so sends that fail due to peer downtime survive restarts.
118
+
119
+ All public methods are **fail-soft**: SQLite errors are logged but
120
+ never propagated to callers so the online send path is never blocked
121
+ by outbox failures. The sole exception is ``__init__``: if the table
122
+ cannot be created ``_active`` is set to False and every subsequent
123
+ method becomes a no-op — callers check ``_active`` before use.
124
+
125
+ Design choices
126
+ --------------
127
+ *Drop oldest on cap*: when a peer_url accumulates 50 rows, the oldest
128
+ row is evicted before the new one is inserted. This keeps a
129
+ permanently-down peer from filling the DB and ensures newer (more
130
+ relevant) messages are preserved. Clients must expect at-least-once
131
+ delivery semantics regardless.
132
+
133
+ *No header replay*: headers stored here are informational/audit-only.
134
+ The drain loop in RemoteSyncClient re-signs each message fresh with a
135
+ new nonce + timestamp to avoid stale-timestamp rejections at the peer.
136
+
137
+ *Bounded drain*: due() returns at most _BATCH_LIMIT rows so each
138
+ sync-loop cycle has a predictable worst-case duration.
139
+ """
140
+
141
+ def __init__(self, db_path: str) -> None:
142
+ self._db_path = db_path
143
+ self._active: bool = False
144
+ self._init_table()
145
+
146
+ # ------------------------------------------------------------------
147
+ # Internal helpers
148
+ # ------------------------------------------------------------------
149
+
150
+ def _connect(self) -> sqlite3.Connection:
151
+ conn = sqlite3.connect(self._db_path, check_same_thread=False, timeout=5.0)
152
+ conn.row_factory = sqlite3.Row
153
+ conn.execute("PRAGMA journal_mode=WAL")
154
+ conn.execute("PRAGMA synchronous=NORMAL")
155
+ return conn
156
+
157
+ def _init_table(self) -> None:
158
+ """Create the outbox table and index idempotently.
159
+
160
+ Uses CREATE TABLE/INDEX IF NOT EXISTS so calling this on an existing
161
+ DB with the table already present is safe (backward-compatible).
162
+ Sets _active=False on any error so every subsequent method no-ops.
163
+ """
164
+ try:
165
+ conn = self._connect()
166
+ try:
167
+ conn.execute(_CREATE_TABLE_SQL)
168
+ conn.execute(_CREATE_INDEX_SQL)
169
+ conn.commit()
170
+ finally:
171
+ conn.close()
172
+ self._active = True
173
+ logger.debug("RemoteOutbox: table ready at %s", self._db_path)
174
+ except sqlite3.Error as exc:
175
+ logger.error(
176
+ "RemoteOutbox: failed to initialise table at %s — outbox "
177
+ "disabled (online send path unaffected): %s",
178
+ self._db_path,
179
+ exc,
180
+ )
181
+
182
+ # ------------------------------------------------------------------
183
+ # Public API
184
+ # ------------------------------------------------------------------
185
+
186
+ def enqueue(
187
+ self,
188
+ peer_url: str,
189
+ to_peer: str,
190
+ payload: dict[str, Any],
191
+ headers: dict[str, str] | None,
192
+ now: float,
193
+ ) -> None:
194
+ """Persist a message for later delivery.
195
+
196
+ *Cap enforcement*: when peer_url already has _CAP_PER_PEER (50) rows,
197
+ the oldest is evicted (FIFO) before insertion so a permanently-down
198
+ peer never exhausts the DB.
199
+
200
+ *TTL*: 48h from ``now`` — consistent with broker MESSAGE_TTL_HOURS.
201
+
202
+ *next_retry_at = now* so the drain loop attempts delivery on the
203
+ very next sync cycle (~30s after enqueueing).
204
+
205
+ Args:
206
+ peer_url: Remote peer base URL (cap scope key).
207
+ to_peer: Target peer ID on remote machine.
208
+ payload: Message dict — serialised as JSON.
209
+ headers: HTTP headers at time of original send — serialised as
210
+ JSON for audit purposes. The drain re-signs fresh headers
211
+ rather than replaying these to avoid stale-timestamp errors.
212
+ now: Epoch seconds (caller-supplied for testability).
213
+ """
214
+ if not self._active:
215
+ return
216
+ try:
217
+ payload_str = json.dumps(payload)
218
+ # Defense-in-depth (audit P0): never persist auth material. Callers
219
+ # pass headers=None; scrub sensitive keys here too so a future
220
+ # caller cannot leak a bearer token / signature into the DB.
221
+ headers_str = None
222
+ if headers is not None:
223
+ safe_headers = {
224
+ k: v for k, v in headers.items()
225
+ if k.lower() not in _SENSITIVE_HEADER_KEYS
226
+ }
227
+ headers_str = json.dumps(safe_headers) if safe_headers else None
228
+ expires_at = now + _TTL_SECONDS
229
+
230
+ conn = self._connect()
231
+ try:
232
+ count_row = conn.execute(
233
+ "SELECT COUNT(*) FROM mesh_outbox_remote WHERE peer_url=?",
234
+ (peer_url,),
235
+ ).fetchone()
236
+ count = count_row[0] if count_row else 0
237
+
238
+ if count >= _CAP_PER_PEER:
239
+ # Evict the single oldest row to make room.
240
+ conn.execute(
241
+ """
242
+ DELETE FROM mesh_outbox_remote
243
+ WHERE id = (
244
+ SELECT id FROM mesh_outbox_remote
245
+ WHERE peer_url = ?
246
+ ORDER BY created_at ASC
247
+ LIMIT 1
248
+ )
249
+ """,
250
+ (peer_url,),
251
+ )
252
+ logger.debug(
253
+ "RemoteOutbox: per-peer cap reached for %s — evicted oldest row",
254
+ peer_url,
255
+ )
256
+
257
+ conn.execute(
258
+ """
259
+ INSERT INTO mesh_outbox_remote
260
+ (peer_url, to_peer, payload, headers,
261
+ retry_count, next_retry_at, created_at, expires_at)
262
+ VALUES (?, ?, ?, ?, 0, ?, ?, ?)
263
+ """,
264
+ (peer_url, to_peer, payload_str, headers_str, now, now, expires_at),
265
+ )
266
+ conn.commit()
267
+ logger.debug(
268
+ "RemoteOutbox: enqueued message for %s → %s",
269
+ peer_url,
270
+ to_peer,
271
+ )
272
+ finally:
273
+ conn.close()
274
+ except (sqlite3.Error, TypeError, ValueError) as exc:
275
+ # TypeError/ValueError guard non-JSON-serializable payloads (audit
276
+ # P2) so enqueue never propagates into the online send path.
277
+ logger.error("RemoteOutbox.enqueue: enqueue failed: %s", exc)
278
+
279
+ def due(self, now: float) -> list[sqlite3.Row]:
280
+ """Return up to _BATCH_LIMIT rows ready for re-delivery (oldest first).
281
+
282
+ A row is due when ``next_retry_at <= now`` and ``expires_at > now``.
283
+ Bounded by _BATCH_LIMIT to cap sync-thread blocking time.
284
+ """
285
+ if not self._active:
286
+ return []
287
+ try:
288
+ conn = self._connect()
289
+ try:
290
+ rows = conn.execute(
291
+ """
292
+ SELECT id, peer_url, to_peer, payload, headers,
293
+ retry_count, next_retry_at, created_at, expires_at
294
+ FROM mesh_outbox_remote
295
+ WHERE next_retry_at <= ? AND expires_at > ?
296
+ ORDER BY next_retry_at ASC
297
+ LIMIT ?
298
+ """,
299
+ (now, now, _BATCH_LIMIT),
300
+ ).fetchall()
301
+ return list(rows)
302
+ finally:
303
+ conn.close()
304
+ except sqlite3.Error as exc:
305
+ logger.error("RemoteOutbox.due: DB error: %s", exc)
306
+ return []
307
+
308
+ def mark_retry(self, row_id: int, now: float) -> None:
309
+ """Increment retry_count and schedule the next attempt with backoff.
310
+
311
+ Deletes the row (dead-letters it) if:
312
+ - ``retry_count + 1 > _MAX_RETRIES`` — too many failures, or
313
+ - ``now >= expires_at`` — TTL elapsed since enqueue.
314
+
315
+ Backoff uses ±25% jitter to prevent thundering-herd.
316
+ """
317
+ if not self._active:
318
+ return
319
+ try:
320
+ conn = self._connect()
321
+ try:
322
+ row = conn.execute(
323
+ "SELECT retry_count, expires_at FROM mesh_outbox_remote WHERE id=?",
324
+ (row_id,),
325
+ ).fetchone()
326
+
327
+ if row is None:
328
+ return # Already deleted by a concurrent drain cycle
329
+
330
+ new_count = row["retry_count"] + 1
331
+ expires_at = row["expires_at"]
332
+
333
+ if new_count > _MAX_RETRIES or now >= expires_at:
334
+ conn.execute(
335
+ "DELETE FROM mesh_outbox_remote WHERE id=?",
336
+ (row_id,),
337
+ )
338
+ conn.commit()
339
+ logger.debug(
340
+ "RemoteOutbox: row %d dead-lettered "
341
+ "(retries=%d, ttl_expired=%s)",
342
+ row_id,
343
+ new_count,
344
+ now >= expires_at,
345
+ )
346
+ return
347
+
348
+ next_retry = now + _backoff(new_count)
349
+ conn.execute(
350
+ """
351
+ UPDATE mesh_outbox_remote
352
+ SET retry_count = ?, next_retry_at = ?
353
+ WHERE id = ?
354
+ """,
355
+ (new_count, next_retry, row_id),
356
+ )
357
+ conn.commit()
358
+ logger.debug(
359
+ "RemoteOutbox: row %d rescheduled (attempt=%d, next=+%.0fs)",
360
+ row_id,
361
+ new_count,
362
+ next_retry - now,
363
+ )
364
+ finally:
365
+ conn.close()
366
+ except sqlite3.Error as exc:
367
+ logger.error("RemoteOutbox.mark_retry(%d): DB error: %s", row_id, exc)
368
+
369
+ def delete(self, row_id: int) -> None:
370
+ """Remove a successfully-delivered row."""
371
+ if not self._active:
372
+ return
373
+ try:
374
+ conn = self._connect()
375
+ try:
376
+ conn.execute(
377
+ "DELETE FROM mesh_outbox_remote WHERE id=?",
378
+ (row_id,),
379
+ )
380
+ conn.commit()
381
+ finally:
382
+ conn.close()
383
+ except sqlite3.Error as exc:
384
+ logger.error("RemoteOutbox.delete(%d): DB error: %s", row_id, exc)
385
+
386
+ def prune_expired(self, now: float) -> None:
387
+ """Delete all rows whose TTL has elapsed."""
388
+ if not self._active:
389
+ return
390
+ try:
391
+ conn = self._connect()
392
+ try:
393
+ deleted = conn.execute(
394
+ "DELETE FROM mesh_outbox_remote WHERE expires_at <= ?",
395
+ (now,),
396
+ ).rowcount
397
+ conn.commit()
398
+ if deleted:
399
+ logger.debug("RemoteOutbox: pruned %d expired rows", deleted)
400
+ finally:
401
+ conn.close()
402
+ except sqlite3.Error as exc:
403
+ logger.error("RemoteOutbox.prune_expired: DB error: %s", exc)
404
+
405
+ def row_count(self, peer_url: str | None = None) -> int:
406
+ """Return total rows in the outbox, optionally scoped to a peer_url.
407
+
408
+ Utility method for testing and monitoring.
409
+ """
410
+ if not self._active:
411
+ return 0
412
+ try:
413
+ conn = self._connect()
414
+ try:
415
+ if peer_url is not None:
416
+ row = conn.execute(
417
+ "SELECT COUNT(*) FROM mesh_outbox_remote WHERE peer_url=?",
418
+ (peer_url,),
419
+ ).fetchone()
420
+ else:
421
+ row = conn.execute(
422
+ "SELECT COUNT(*) FROM mesh_outbox_remote",
423
+ ).fetchone()
424
+ return row[0] if row else 0
425
+ finally:
426
+ conn.close()
427
+ except sqlite3.Error as exc:
428
+ logger.error("RemoteOutbox.row_count: DB error: %s", exc)
429
+ return 0