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,365 @@
1
+ """mDNS/DNS-SD advertising for the SLM daemon mesh substrate.
2
+
3
+ This module provides :class:`MeshAdvertiser`, which registers the running SLM
4
+ daemon as a ``_slm-mesh._tcp.local.`` service so that other SLM instances on
5
+ the same LAN can discover it via :class:`~superlocalmemory.mesh.remote_sync.RemoteSyncClient`.
6
+
7
+ Advertising is **opt-in and disabled by default** (backward-compatible).
8
+ Existing installations see zero behavior change. Enable via::
9
+
10
+ SLM_MESH_ADVERTISE=1 # also accepts: on, true, yes (case-insensitive)
11
+
12
+ WHY OPT-IN: SLM runs on corporate-managed endpoints where unsolicited mDNS
13
+ multicast traffic may violate network policy or trigger security tooling.
14
+ Advertising fires only when an operator explicitly sets ``SLM_MESH_ADVERTISE``.
15
+
16
+ Service type: ``_slm-mesh._tcp.local.``
17
+ This MUST match the type browsed by :class:`RemoteSyncClient` in
18
+ ``superlocalmemory.mesh.remote_sync`` (grep ``_slm-mesh._tcp.local.``).
19
+
20
+ Instance name: ``slm-{node_id}-{port}._slm-mesh._tcp.local.``
21
+ The port suffix prevents :exc:`~zeroconf.NonUniqueNameException` when two
22
+ SLM daemon instances run on the same host (e.g. primary on 8765, test on 8766).
23
+
24
+ Properties TXT record: contains ``node_id`` and any caller-supplied metadata.
25
+ **NEVER include secrets, tokens, or passwords** — TXT records are broadcast in
26
+ plaintext across the LAN and are visible to every host on the subnet.
27
+
28
+ All operations are fail-soft: any error is logged as WARNING and the daemon
29
+ continues. ``stop()`` is idempotent.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import logging
35
+ import os
36
+ import socket
37
+ import threading
38
+ from socket import inet_aton
39
+ from typing import Any, Optional
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Constants
45
+ # ---------------------------------------------------------------------------
46
+
47
+ _SERVICE_TYPE: str = "_slm-mesh._tcp.local."
48
+ """DNS-SD service type — must match what RemoteSyncClient browses."""
49
+
50
+ _ADVERTISE_TRUTHY: frozenset[str] = frozenset({"1", "on", "true", "yes"})
51
+ """Accepted values for SLM_MESH_ADVERTISE (matched case-insensitively)."""
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Optional zeroconf dependency guard
55
+ # Mirrors the exact pattern used in remote_sync.py so both modules behave
56
+ # identically when zeroconf is not installed.
57
+ # ---------------------------------------------------------------------------
58
+ try:
59
+ from zeroconf import ServiceInfo, Zeroconf
60
+
61
+ ZEROCONF_AVAILABLE: bool = True
62
+ except ImportError:
63
+ ZEROCONF_AVAILABLE = False
64
+ Zeroconf = None # type: ignore[assignment,misc]
65
+ ServiceInfo = None # type: ignore[assignment]
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Internal helpers
70
+ # ---------------------------------------------------------------------------
71
+
72
+
73
+ def _is_advertising_enabled() -> bool:
74
+ """Return True only when ``SLM_MESH_ADVERTISE`` is set to a truthy value.
75
+
76
+ Case-insensitive: ``'1'``, ``'on'``, ``'true'``, ``'yes'`` are accepted.
77
+ Everything else (including unset) returns False (default-OFF, BC-safe).
78
+ """
79
+ return (
80
+ os.environ.get("SLM_MESH_ADVERTISE", "").strip().lower()
81
+ in _ADVERTISE_TRUTHY
82
+ )
83
+
84
+
85
+ def _resolve_advertise_ip(injected_ip: Optional[str] = None) -> str:
86
+ """Return the best local non-loopback IPv4 address for mDNS advertising.
87
+
88
+ Uses a UDP connect trick (no packet is sent — the kernel consults the
89
+ routing table to decide which source address would be used to reach a
90
+ well-known public address). Falls back to ``127.0.0.1`` when:
91
+
92
+ * No routable interface is found.
93
+ * The resolved address is itself a loopback address.
94
+ * Any :exc:`OSError` is raised (e.g. no network at all).
95
+
96
+ The ``127.0.0.1`` fallback allows two-daemon integration tests on the
97
+ loopback interface to work without a real LAN.
98
+
99
+ Args:
100
+ injected_ip: When provided, skip resolution entirely and return this
101
+ value. Used by tests to avoid real socket calls.
102
+
103
+ Returns:
104
+ A dotted-decimal IPv4 address string.
105
+ """
106
+ if injected_ip is not None:
107
+ return injected_ip
108
+ # Operator override for multi-homed / VPN hosts where the auto-detected
109
+ # route would advertise the wrong NIC (audit P2). Explicit wins.
110
+ env_ip = os.environ.get("SLM_MESH_ADVERTISE_IP", "").strip()
111
+ if env_ip:
112
+ return env_ip
113
+ try:
114
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
115
+ sock.connect(("8.8.8.8", 80))
116
+ ip: str = sock.getsockname()[0]
117
+ if ip and not ip.startswith("127."):
118
+ return ip
119
+ except OSError:
120
+ pass
121
+ # No routable non-loopback interface — peers off this host cannot reach a
122
+ # 127.0.0.1 advertisement. Warn so the operator can set SLM_MESH_ADVERTISE_IP.
123
+ logger.warning(
124
+ "MeshAdvertiser: no routable non-loopback IPv4 found; advertising "
125
+ "127.0.0.1 (off-host peers cannot reach this node). Set "
126
+ "SLM_MESH_ADVERTISE_IP to advertise a reachable address."
127
+ )
128
+ return "127.0.0.1"
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # MeshAdvertiser
133
+ # ---------------------------------------------------------------------------
134
+
135
+
136
+ class MeshAdvertiser:
137
+ """Register this SLM daemon as a discoverable mDNS service on the LAN.
138
+
139
+ Advertising is **disabled by default** (opt-in via ``SLM_MESH_ADVERTISE``).
140
+
141
+ Lifecycle::
142
+
143
+ advertiser = MeshAdvertiser(service_port=8765, node_id="hostname")
144
+ advertiser.start() # no-op unless SLM_MESH_ADVERTISE is truthy
145
+ ...
146
+ advertiser.stop() # idempotent; safe to call even if never started
147
+
148
+ Thread safety:
149
+ ``start()`` and ``stop()`` are protected by a :class:`threading.Lock`.
150
+ The lock prevents double-registration and eliminates the race between
151
+ a slow ``register_service`` call and a concurrent ``stop()`` call:
152
+ ``start()`` holds the lock for the entire registration so ``stop()``
153
+ sees a consistent, fully-initialised state.
154
+
155
+ CRIT fixes applied:
156
+ 1. **Instance-name collision** — port is embedded in the instance label
157
+ (``slm-{node_id}-{port}``), so two daemons on the same host register
158
+ distinct names and avoid :exc:`~zeroconf.NonUniqueNameException`.
159
+ 2. **Event-loop blocking** — ``register_service`` probes for ~750 ms.
160
+ Callers on an async startup path MUST wrap ``start()`` in
161
+ ``asyncio.to_thread(advertiser.start)`` (see lifespan wiring in
162
+ ``unified_daemon.py``). ``MeshAdvertiser`` itself remains sync so
163
+ it is trivially testable without an event loop.
164
+ 3. **stop() / register race** — ``_lock`` is held for the entire
165
+ duration of ``_start_locked()``, so a concurrent ``stop()`` blocks
166
+ until registration completes. ``stop()`` clears state under the
167
+ lock before releasing it, preventing double-close.
168
+ """
169
+
170
+ def __init__(
171
+ self,
172
+ service_port: int,
173
+ node_id: str,
174
+ properties: Optional[dict[str, str]] = None,
175
+ *,
176
+ _injected_ip: Optional[str] = None,
177
+ ) -> None:
178
+ """Create a MeshAdvertiser.
179
+
180
+ Args:
181
+ service_port: TCP port the SLM HTTP daemon is bound to.
182
+ node_id: Stable unique identifier for this node (hostname is a
183
+ good choice). Must be safe for DNS labels.
184
+ properties: Optional extra metadata broadcast in the mDNS TXT
185
+ record. Keep tiny. **NEVER include secrets** — TXT
186
+ records are visible in plaintext to every host on the
187
+ LAN.
188
+ _injected_ip: Test hook — override IP resolution. Not part of the
189
+ public API.
190
+ """
191
+ self._service_port: int = service_port
192
+ self._node_id: str = node_id
193
+ # Defensive copy — immutable style; caller can't mutate our dict later.
194
+ self._properties: dict[str, str] = dict(properties) if properties else {}
195
+ self._injected_ip: Optional[str] = _injected_ip
196
+
197
+ # Mutable state — all mutations happen under _lock.
198
+ self._zeroconf: Any = None
199
+ self._info: Any = None
200
+ self._advertising: bool = False
201
+ self._lock: threading.Lock = threading.Lock()
202
+
203
+ # ------------------------------------------------------------------
204
+ # Public API
205
+ # ------------------------------------------------------------------
206
+
207
+ def start(self) -> None:
208
+ """Register the SLM service in mDNS.
209
+
210
+ No-op when:
211
+ * zeroconf is not installed (``ZEROCONF_AVAILABLE is False``).
212
+ * ``SLM_MESH_ADVERTISE`` is unset or not in the truthy set.
213
+
214
+ Any exception from the zeroconf stack is caught, logged as WARNING,
215
+ and swallowed — the daemon must NEVER fail to start because of
216
+ optional mDNS advertising.
217
+
218
+ Note:
219
+ ``register_service`` blocks for ~750 ms (mDNS probe phase).
220
+ Call this from an async context via ``asyncio.to_thread(advertiser.start)``.
221
+ """
222
+ if not ZEROCONF_AVAILABLE:
223
+ logger.debug(
224
+ "MeshAdvertiser.start: zeroconf not installed; skipping mDNS advertising"
225
+ )
226
+ return
227
+ if not _is_advertising_enabled():
228
+ logger.debug(
229
+ "MeshAdvertiser.start: SLM_MESH_ADVERTISE not set (or not truthy); "
230
+ "mDNS advertising remains disabled (default-OFF, BC-safe)"
231
+ )
232
+ return
233
+
234
+ with self._lock:
235
+ if self._advertising:
236
+ # Idempotent — already registered; nothing to do.
237
+ return
238
+ try:
239
+ self._start_locked()
240
+ except Exception as exc:
241
+ logger.warning(
242
+ "MeshAdvertiser.start failed (non-fatal, daemon continues): %s", exc
243
+ )
244
+ # Clean up any partial state so stop() is still safe.
245
+ self._zeroconf = None
246
+ self._info = None
247
+ self._advertising = False
248
+
249
+ def stop(self) -> None:
250
+ """Unregister the mDNS service and close Zeroconf.
251
+
252
+ Idempotent — safe to call multiple times or when ``start()`` was never
253
+ called. Any exception is caught and logged as WARNING.
254
+
255
+ CRIT note: state is cleared under ``_lock`` *before* calling
256
+ ``unregister_service`` / ``close``, so a second concurrent ``stop()``
257
+ sees ``_advertising=False`` and exits immediately without attempting
258
+ to close an already-closed Zeroconf instance.
259
+ """
260
+ with self._lock:
261
+ if not self._advertising:
262
+ return
263
+ # Capture and clear state under the lock atomically.
264
+ zc: Any = self._zeroconf
265
+ info: Any = self._info
266
+ self._advertising = False
267
+ self._zeroconf = None
268
+ self._info = None
269
+
270
+ # Perform I/O outside the lock so a concurrent stop() call (highly
271
+ # unlikely given daemon lifecycle, but possible in tests) can't
272
+ # deadlock waiting for us.
273
+ # Audit P1: close() MUST run even when unregister_service raises,
274
+ # otherwise the Zeroconf multicast sockets + threads leak. Best-effort
275
+ # unregister, then close in a finally.
276
+ try:
277
+ if zc is not None and info is not None:
278
+ try:
279
+ zc.unregister_service(info)
280
+ except Exception as exc:
281
+ logger.warning(
282
+ "MeshAdvertiser.stop: unregister failed (closing anyway): %s",
283
+ exc,
284
+ )
285
+ finally:
286
+ if zc is not None:
287
+ try:
288
+ zc.close()
289
+ except Exception as exc:
290
+ logger.warning(
291
+ "MeshAdvertiser.stop: close failed (non-fatal): %s", exc
292
+ )
293
+
294
+ @property
295
+ def is_advertising(self) -> bool:
296
+ """True when the service is currently registered in mDNS."""
297
+ return self._advertising
298
+
299
+ # ------------------------------------------------------------------
300
+ # Internal
301
+ # ------------------------------------------------------------------
302
+
303
+ def _start_locked(self) -> None:
304
+ """Build :class:`~zeroconf.ServiceInfo` and register it.
305
+
306
+ Called under ``self._lock``. Raises on any error; the caller
307
+ (``start()``) catches and logs.
308
+ """
309
+ ip: str = _resolve_advertise_ip(self._injected_ip)
310
+
311
+ # Instance label carries port AND pid so the mDNS name is unique:
312
+ # - port disambiguates two daemons on the SAME host (CRIT fix #1);
313
+ # - pid disambiguates two machines that share a short hostname on the
314
+ # segment (audit P2 — cross-machine NonUniqueNameException).
315
+ # The TXT node_id stays hostname-only (see _properties below).
316
+ instance_label: str = f"slm-{self._node_id}-{self._service_port}-{os.getpid()}"
317
+ service_name: str = f"{instance_label}.{_SERVICE_TYPE}"
318
+
319
+ # Build bytes-keyed properties dict (zeroconf API requirement).
320
+ # SECURITY: only whitelisted string content — never mirror env vars.
321
+ # TXT records are broadcast in plaintext to the entire LAN subnet.
322
+ safe_props: dict[str, str] = {
323
+ "node_id": self._node_id,
324
+ **self._properties,
325
+ }
326
+ byte_props: dict[bytes, bytes] = {
327
+ k.encode("utf-8"): v.encode("utf-8") for k, v in safe_props.items()
328
+ }
329
+
330
+ info = ServiceInfo(
331
+ type_=_SERVICE_TYPE,
332
+ name=service_name,
333
+ addresses=[inet_aton(ip)],
334
+ port=self._service_port,
335
+ properties=byte_props,
336
+ )
337
+
338
+ zc = Zeroconf()
339
+ # register_service sends mDNS probe + announce packets.
340
+ # This call blocks for ~750 ms (3 × 250 ms probe interval).
341
+ # Callers on the async event loop MUST use asyncio.to_thread().
342
+ try:
343
+ zc.register_service(info)
344
+ except Exception:
345
+ # Audit P1: register_service failed AFTER Zeroconf() opened its
346
+ # multicast sockets + background threads. Close it before
347
+ # propagating so those resources are released instead of leaking
348
+ # for the daemon's lifetime (start() catches and logs).
349
+ try:
350
+ zc.close()
351
+ except Exception: # pragma: no cover — best-effort cleanup
352
+ pass
353
+ raise
354
+
355
+ # Only update state after successful registration.
356
+ self._zeroconf = zc
357
+ self._info = info
358
+ self._advertising = True
359
+
360
+ logger.info(
361
+ "MeshAdvertiser: registered '%s' on %s:%d",
362
+ service_name,
363
+ ip,
364
+ self._service_port,
365
+ )
@@ -0,0 +1,313 @@
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
+ """SLM Mesh — leaderless cross-node lock coordination (3c-2).
6
+
7
+ SAFETY MODEL (never overclaim):
8
+ ``resolve()`` converges the ADVISORY lock VIEW across nodes using a
9
+ deterministic ``(fencing_token, node_id)`` total order. Higher wins.
10
+ When the remote node wins, the local ``mesh_locks`` row is deleted so
11
+ the local node's token becomes stale.
12
+
13
+ The FENCING TOKEN (``broker.validate_lock_fence``) is the single-writer
14
+ SAFETY guarantee: even during a brief split where both nodes held the
15
+ lock, any write attempt with the yielded node's old token is REJECTED
16
+ by the storage layer — because either:
17
+ (a) the row is gone → "no lock held for this resource", or
18
+ (b) the winning node has since acquired with a higher token →
19
+ "fencing token X is stale; current token is Y".
20
+
21
+ **The advisory lock alone does NOT guarantee mutual exclusion; only the
22
+ fence does.** This is NOT linearizable consensus (which requires a
23
+ quorum).
24
+
25
+ All new behavior is ADDITIVE and only active when ``resolve()`` is called
26
+ by the sync layer. Node-local ``lock_action`` behavior is UNCHANGED.
27
+
28
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import logging
34
+ import sqlite3
35
+ from datetime import datetime, timezone
36
+ from typing import TYPE_CHECKING
37
+
38
+ from superlocalmemory.mesh.broker import _NEVER_EXPIRES
39
+ from superlocalmemory.mesh.node_identity import get_node_id
40
+
41
+ if TYPE_CHECKING:
42
+ from superlocalmemory.mesh.broker import MeshBroker
43
+
44
+ logger = logging.getLogger("superlocalmemory.mesh.lock_protocol")
45
+
46
+
47
+ class LockCoordinator:
48
+ """Leaderless cross-node advisory lock coordinator.
49
+
50
+ Wraps a ``MeshBroker`` instance and adds the cross-machine protocol
51
+ layer: export local live locks (``local_lock_delta``) and converge
52
+ the lock view against a remote peer's claims (``resolve``).
53
+
54
+ All database operations are fail-soft: errors are logged and an empty
55
+ / no-op result is returned so callers (including the daemon sync loop)
56
+ are never interrupted.
57
+ """
58
+
59
+ def __init__(self, broker: "MeshBroker") -> None:
60
+ self._broker = broker
61
+ self._db_path: str = broker._db_path
62
+ self._node_id: str = get_node_id(self._db_path)
63
+
64
+ # ------------------------------------------------------------------
65
+ # Public API
66
+ # ------------------------------------------------------------------
67
+
68
+ def local_lock_delta(
69
+ self,
70
+ profile_id: str = "default",
71
+ now_iso: str | None = None,
72
+ ) -> list[dict]:
73
+ """Return live local locks for a profile, annotated with this node's id.
74
+
75
+ A lock is *live* iff its ``expires_at`` is set, not the legacy
76
+ ``_NEVER_EXPIRES`` sentinel, and not yet elapsed.
77
+
78
+ Args:
79
+ profile_id: Tenant scope (default ``"default"``).
80
+ now_iso: UTC ISO timestamp; injectable for test determinism.
81
+
82
+ Returns:
83
+ List of ``{"file_path","locked_by","locked_at","expires_at",
84
+ "fencing_token","node_id"}`` dicts. ``[]`` on DB error (fail-soft).
85
+ """
86
+ now: str = now_iso or datetime.now(timezone.utc).isoformat()
87
+ conn: sqlite3.Connection | None = None
88
+ try:
89
+ conn = sqlite3.connect(self._db_path, timeout=5.0)
90
+ conn.row_factory = sqlite3.Row
91
+ rows = conn.execute(
92
+ "SELECT file_path, locked_by, locked_at, expires_at,"
93
+ " COALESCE(fencing_token, 0) AS fencing_token"
94
+ " FROM mesh_locks WHERE profile_id=?",
95
+ (profile_id,),
96
+ ).fetchall()
97
+ except sqlite3.Error as exc:
98
+ logger.error("local_lock_delta: DB error for profile %r: %s", profile_id, exc)
99
+ return []
100
+ finally:
101
+ if conn is not None:
102
+ conn.close()
103
+
104
+ result: list[dict] = []
105
+ for row in rows:
106
+ if not self._row_is_live(row["expires_at"], now):
107
+ continue
108
+ result.append({
109
+ "file_path": row["file_path"],
110
+ "locked_by": row["locked_by"],
111
+ "locked_at": row["locked_at"],
112
+ "expires_at": row["expires_at"],
113
+ "fencing_token": int(row["fencing_token"]),
114
+ "node_id": self._node_id,
115
+ })
116
+ return result
117
+
118
+ def resolve(
119
+ self,
120
+ profile_id: str,
121
+ remote_locks: list[dict],
122
+ ) -> dict:
123
+ """Converge the local lock view against a remote peer's live claims.
124
+
125
+ For each *live* remote lock, compare it to the local ``mesh_locks``
126
+ row using ``(fencing_token DESC, node_id DESC)``. Remote wins →
127
+ delete local row (token goes stale; fence will reject it). Only
128
+ ``file_path`` values in ``remote_locks`` are examined (others untouched).
129
+ Idempotent: a second call after a yield is a no-op.
130
+
131
+ Args:
132
+ profile_id: Tenant scope.
133
+ remote_locks: Dicts from the remote peer's ``local_lock_delta``
134
+ (must include ``file_path``, ``fencing_token``,
135
+ ``node_id``, ``expires_at``).
136
+
137
+ Returns:
138
+ ``{"yielded": [paths], "kept": N}``.
139
+ ``{"yielded": [], "kept": 0}`` on any error (fail-soft).
140
+ """
141
+ try:
142
+ return self._resolve_inner(profile_id, remote_locks)
143
+ except Exception as exc: # broad catch — must never crash daemon
144
+ logger.error(
145
+ "resolve: unexpected error for profile %r: %s",
146
+ profile_id, exc,
147
+ exc_info=True,
148
+ )
149
+ return {"yielded": [], "kept": 0}
150
+
151
+ # ------------------------------------------------------------------
152
+ # Internal helpers
153
+ # ------------------------------------------------------------------
154
+
155
+ def _resolve_inner(
156
+ self,
157
+ profile_id: str,
158
+ remote_locks: list[dict],
159
+ ) -> dict:
160
+ """Core resolve logic — separated so the outer method can catch all errors."""
161
+ now: str = datetime.now(timezone.utc).isoformat()
162
+
163
+ # Build a map of file_path → strongest LIVE remote claim.
164
+ # Audit P2: a non-dict entry must never crash resolve (per-entry skip).
165
+ # Audit P2: if a caller ever concatenates multiple peers' deltas, keep
166
+ # the winner under the SAME total order (token, node_id) rather than
167
+ # letting a later, weaker claim overwrite a stronger one.
168
+ live_remote: dict[str, dict] = {}
169
+ for rlock in remote_locks:
170
+ if not isinstance(rlock, dict):
171
+ continue
172
+ fp = rlock.get("file_path", "")
173
+ if not fp or not self._dict_is_live(rlock, now):
174
+ continue
175
+ existing = live_remote.get(fp)
176
+ if existing is None or self._lock_key(rlock) > self._lock_key(existing):
177
+ live_remote[fp] = rlock
178
+
179
+ if not live_remote:
180
+ return {"yielded": [], "kept": 0}
181
+
182
+ # Fetch local rows for the contested file_paths only — including
183
+ # expires_at (audit P1: an EXPIRED local row must not win over a live
184
+ # remote claim just because it carries a higher token).
185
+ file_paths: tuple[str, ...] = tuple(live_remote.keys())
186
+ placeholders = ",".join("?" * len(file_paths))
187
+ conn: sqlite3.Connection = sqlite3.connect(self._db_path, timeout=5.0)
188
+ try:
189
+ conn.row_factory = sqlite3.Row
190
+ try:
191
+ local_rows = conn.execute(
192
+ f"SELECT file_path, expires_at,"
193
+ f" COALESCE(fencing_token, 0) AS fencing_token"
194
+ f" FROM mesh_locks WHERE profile_id=? AND file_path IN ({placeholders})",
195
+ (profile_id, *file_paths),
196
+ ).fetchall()
197
+ except sqlite3.Error as exc:
198
+ logger.error(
199
+ "resolve: failed to query local locks for profile %r: %s",
200
+ profile_id, exc,
201
+ )
202
+ return {"yielded": [], "kept": 0}
203
+
204
+ local_by_path: dict[str, dict] = {
205
+ row["file_path"]: {
206
+ "fencing_token": int(row["fencing_token"]),
207
+ "expires_at": row["expires_at"],
208
+ }
209
+ for row in local_rows
210
+ }
211
+
212
+ yielded: list[str] = []
213
+ kept: int = 0
214
+
215
+ for fp, rlock in live_remote.items():
216
+ local = local_by_path.get(fp)
217
+ if local is None:
218
+ continue # No local row — nothing to yield or keep.
219
+
220
+ snapshot_token: int = local["fencing_token"]
221
+ local_live: bool = self._row_is_live(local["expires_at"], now)
222
+
223
+ # Yield when: (a) the local row is EXPIRED garbage while a live
224
+ # remote claim exists (audit P1 — clean it up so its stale token
225
+ # can't pass the fence), or (b) the live remote strictly wins the
226
+ # total order.
227
+ if local_live and not self._remote_wins(rlock, local):
228
+ kept += 1
229
+ continue
230
+
231
+ # Token-CONDITIONAL delete (audit P1/TOCTOU): only delete the
232
+ # row we compared against. If the local node reacquired to a new
233
+ # (higher) token between snapshot and now, the predicate misses
234
+ # and we do NOT wipe the fresher live lock.
235
+ try:
236
+ cur = conn.execute(
237
+ "DELETE FROM mesh_locks WHERE profile_id=? AND file_path=?"
238
+ " AND COALESCE(fencing_token, 0)=?",
239
+ (profile_id, fp, snapshot_token),
240
+ )
241
+ conn.commit()
242
+ if cur.rowcount and cur.rowcount > 0:
243
+ logger.info(
244
+ "resolve: yielded lock %r to remote node %r "
245
+ "(remote (tok=%s,node=%s) vs local (tok=%s), local_live=%s)",
246
+ fp, rlock.get("node_id", "?"), self._safe_token(rlock),
247
+ rlock.get("node_id", "?"), snapshot_token, local_live,
248
+ )
249
+ yielded.append(fp)
250
+ # rowcount==0 → lost the race (reacquired); leave it, don't count.
251
+ except sqlite3.Error as exc:
252
+ logger.error(
253
+ "resolve: failed to yield lock %r for profile %r: %s",
254
+ fp, profile_id, exc,
255
+ )
256
+
257
+ return {"yielded": yielded, "kept": kept}
258
+ finally:
259
+ conn.close()
260
+
261
+ @staticmethod
262
+ def _row_is_live(expires_at: str | None, now_iso: str) -> bool:
263
+ """Return True iff a DB row's expires_at represents a live lock."""
264
+ if not expires_at:
265
+ return False
266
+ if expires_at == _NEVER_EXPIRES:
267
+ return False
268
+ return expires_at > now_iso
269
+
270
+ @staticmethod
271
+ def _dict_is_live(lock: dict, now_iso: str) -> bool:
272
+ """Return True iff a remote lock dict represents a live claim."""
273
+ exp = lock.get("expires_at", "") or ""
274
+ if not exp:
275
+ return False
276
+ if exp == _NEVER_EXPIRES:
277
+ return False
278
+ return exp > now_iso
279
+
280
+ @staticmethod
281
+ def _safe_token(lock: dict) -> int:
282
+ """Cast fencing_token to int defensively (remote dicts may carry strings)."""
283
+ try:
284
+ return int(lock.get("fencing_token", 0))
285
+ except (TypeError, ValueError):
286
+ return 0
287
+
288
+ def _lock_key(self, lock: dict) -> tuple[int, str]:
289
+ """Total-order key ``(fencing_token, node_id)`` for comparing claims."""
290
+ return (self._safe_token(lock), str(lock.get("node_id", "")))
291
+
292
+ def _remote_wins(self, remote: dict, local: dict) -> bool:
293
+ """Total order: ``(fencing_token DESC, node_id DESC)``.
294
+
295
+ Compare as ``int`` for the token (critical — JSON may deliver strings,
296
+ and string comparison ``"10" > "9"`` is ``False``, which would invert
297
+ the order). Tie-break via lexicographic ``node_id`` comparison; both
298
+ sides use consistent lowercase hex from ``get_node_id``, so the order
299
+ is stable across nodes.
300
+
301
+ Returns True iff the remote lock should be considered the effective
302
+ holder over the local lock.
303
+ """
304
+ r_tok: int = self._safe_token(remote)
305
+ l_tok: int = local.get("fencing_token", 0)
306
+
307
+ if r_tok != l_tok:
308
+ return r_tok > l_tok
309
+
310
+ # Token tie — use node_id as deterministic tie-breaker.
311
+ r_nid: str = str(remote.get("node_id", ""))
312
+ l_nid: str = self._node_id
313
+ return r_nid > l_nid