superlocalmemory 3.7.8 → 3.8.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 (260) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/ATTRIBUTION.md +1 -3
  3. package/CHANGELOG.md +69 -0
  4. package/README.md +199 -29
  5. package/package.json +4 -2
  6. package/plugin/.claude-plugin/plugin.json +2 -2
  7. package/plugin/CLAUDE.md +8 -8
  8. package/plugin/agents/slm-governance-advisor.md +80 -0
  9. package/plugin/agents/slm-loop-runner.md +71 -0
  10. package/plugin/agents/slm-memory-advisor.md +10 -5
  11. package/plugin/agents/slm-optimize-advisor.md +9 -3
  12. package/plugin/commands/slm-loop.md +31 -0
  13. package/plugin/hooks/hooks.json +79 -0
  14. package/plugin/requirements.txt +1 -1
  15. package/plugin/scripts/slm-launch +46 -7
  16. package/plugin/settings.json +9 -0
  17. package/plugin/skills/slm-cache/SKILL.md +9 -1
  18. package/plugin/skills/slm-compress/SKILL.md +8 -1
  19. package/plugin/skills/slm-governance/SKILL.md +248 -0
  20. package/plugin/skills/slm-graph/SKILL.md +17 -3
  21. package/plugin/skills/slm-loop/SKILL.md +99 -0
  22. package/plugin/skills/slm-mesh/SKILL.md +282 -0
  23. package/plugin/skills/slm-profile/SKILL.md +148 -0
  24. package/plugin/skills/slm-recall/SKILL.md +46 -10
  25. package/plugin/skills/slm-remember/SKILL.md +48 -1
  26. package/plugin/skills/slm-scope/SKILL.md +176 -0
  27. package/plugin/skills/slm-session/SKILL.md +24 -1
  28. package/plugin/skills/slm-status/SKILL.md +18 -1
  29. package/plugin-src/agents/slm-governance-advisor.md +80 -0
  30. package/plugin-src/agents/slm-loop-runner.md +71 -0
  31. package/plugin-src/agents/slm-memory-advisor.md +10 -5
  32. package/plugin-src/agents/slm-optimize-advisor.md +9 -3
  33. package/plugin-src/commands/slm-loop.md +31 -0
  34. package/plugin-src/hooks/hooks.json +79 -0
  35. package/plugin-src/manifest.json +7 -2
  36. package/plugin-src/requirements.txt +1 -1
  37. package/plugin-src/rules/AGENTS.md +57 -18
  38. package/plugin-src/rules/CLAUDE.md.fragment +8 -8
  39. package/plugin-src/scripts/slm-launch +46 -7
  40. package/plugin-src/settings.json +9 -0
  41. package/plugin-src/skills/slm-cache/SKILL.md +9 -1
  42. package/plugin-src/skills/slm-compress/SKILL.md +8 -1
  43. package/plugin-src/skills/slm-governance/SKILL.md +248 -0
  44. package/plugin-src/skills/slm-graph/SKILL.md +17 -3
  45. package/plugin-src/skills/slm-loop/SKILL.md +99 -0
  46. package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
  47. package/plugin-src/skills/slm-profile/SKILL.md +148 -0
  48. package/plugin-src/skills/slm-recall/SKILL.md +46 -10
  49. package/plugin-src/skills/slm-remember/SKILL.md +48 -1
  50. package/plugin-src/skills/slm-scope/SKILL.md +176 -0
  51. package/plugin-src/skills/slm-session/SKILL.md +24 -1
  52. package/plugin-src/skills/slm-status/SKILL.md +18 -1
  53. package/pyproject.toml +1 -1
  54. package/scripts/postinstall/validation.js +2 -0
  55. package/scripts/postinstall-interactive.js +74 -2
  56. package/src/superlocalmemory/__init__.py +1 -1
  57. package/src/superlocalmemory/access/__init__.py +3 -0
  58. package/src/superlocalmemory/access/rbac.py +477 -0
  59. package/src/superlocalmemory/cli/commands.py +94 -10
  60. package/src/superlocalmemory/cli/compress_cmd.py +17 -7
  61. package/src/superlocalmemory/cli/loop_cmd.py +192 -0
  62. package/src/superlocalmemory/cli/main.py +39 -4
  63. package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
  64. package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
  65. package/src/superlocalmemory/cli/pending_store.py +49 -13
  66. package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
  67. package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
  68. package/src/superlocalmemory/cli/setup_wizard.py +22 -13
  69. package/src/superlocalmemory/compliance/audit.py +6 -0
  70. package/src/superlocalmemory/compliance/gdpr.py +128 -138
  71. package/src/superlocalmemory/compliance/retention.py +176 -45
  72. package/src/superlocalmemory/core/backend_orchestrator.py +5 -43
  73. package/src/superlocalmemory/core/community_summary.py +267 -0
  74. package/src/superlocalmemory/core/config.py +216 -3
  75. package/src/superlocalmemory/core/consolidation_engine.py +95 -22
  76. package/src/superlocalmemory/core/context_cache.py +61 -18
  77. package/src/superlocalmemory/core/embedding_worker.py +17 -2
  78. package/src/superlocalmemory/core/embeddings.py +12 -1
  79. package/src/superlocalmemory/core/engine.py +17 -1
  80. package/src/superlocalmemory/core/engine_ingestion.py +29 -0
  81. package/src/superlocalmemory/core/engine_wiring.py +13 -0
  82. package/src/superlocalmemory/core/entity_community.py +178 -0
  83. package/src/superlocalmemory/core/graph_analyzer.py +39 -2
  84. package/src/superlocalmemory/core/graph_pruner.py +13 -8
  85. package/src/superlocalmemory/core/key_expander.py +138 -0
  86. package/src/superlocalmemory/core/maintenance.py +23 -0
  87. package/src/superlocalmemory/core/modes.py +1 -1
  88. package/src/superlocalmemory/core/mutations.py +2 -2
  89. package/src/superlocalmemory/core/pii.py +105 -0
  90. package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
  91. package/src/superlocalmemory/core/recall_pipeline.py +2 -0
  92. package/src/superlocalmemory/core/recall_worker.py +20 -6
  93. package/src/superlocalmemory/core/scale_engine.py +60 -1
  94. package/src/superlocalmemory/core/security_primitives.py +40 -2
  95. package/src/superlocalmemory/core/store_pipeline.py +35 -11
  96. package/src/superlocalmemory/core/worker_pool.py +21 -6
  97. package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
  98. package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
  99. package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
  100. package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
  101. package/src/superlocalmemory/evolution/evolution_store.py +122 -45
  102. package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
  103. package/src/superlocalmemory/evolution/model_selection.py +160 -0
  104. package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
  105. package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
  106. package/src/superlocalmemory/evolution/triggers.py +22 -13
  107. package/src/superlocalmemory/graph/cozo_backend.py +43 -20
  108. package/src/superlocalmemory/hooks/adapter_base.py +5 -1
  109. package/src/superlocalmemory/hooks/auto_recall.py +13 -1
  110. package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
  111. package/src/superlocalmemory/hooks/codex_assets.py +64 -5
  112. package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
  113. package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
  114. package/src/superlocalmemory/hooks/portable_kit.py +114 -1
  115. package/src/superlocalmemory/infra/backup.py +12 -1
  116. package/src/superlocalmemory/infra/daemon_identity.py +40 -4
  117. package/src/superlocalmemory/infra/data_root.py +43 -4
  118. package/src/superlocalmemory/infra/event_bus.py +107 -24
  119. package/src/superlocalmemory/infra/rate_limiter.py +93 -0
  120. package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
  121. package/src/superlocalmemory/ingestion/credentials.py +1 -1
  122. package/src/superlocalmemory/learning/cross_project.py +28 -19
  123. package/src/superlocalmemory/learning/reward_proxy.py +42 -9
  124. package/src/superlocalmemory/loops/__init__.py +56 -0
  125. package/src/superlocalmemory/loops/budget.py +58 -0
  126. package/src/superlocalmemory/loops/engine.py +164 -0
  127. package/src/superlocalmemory/loops/ledger.py +243 -0
  128. package/src/superlocalmemory/loops/models.py +152 -0
  129. package/src/superlocalmemory/loops/rules.py +52 -0
  130. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  131. package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
  132. package/src/superlocalmemory/mcp/profiles.py +103 -0
  133. package/src/superlocalmemory/mcp/server.py +21 -49
  134. package/src/superlocalmemory/mcp/tools_active.py +4 -7
  135. package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
  136. package/src/superlocalmemory/mcp/tools_core.py +8 -1
  137. package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
  138. package/src/superlocalmemory/mcp/tools_loops.py +300 -0
  139. package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
  140. package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
  141. package/src/superlocalmemory/mesh/broker.py +237 -129
  142. package/src/superlocalmemory/mesh/remote_sync.py +50 -8
  143. package/src/superlocalmemory/optimize/NOTICE +1 -6
  144. package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
  145. package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
  146. package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
  147. package/src/superlocalmemory/optimize/compress/align.py +32 -26
  148. package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
  149. package/src/superlocalmemory/optimize/compress/router.py +105 -22
  150. package/src/superlocalmemory/optimize/config/defaults.py +1 -1
  151. package/src/superlocalmemory/optimize/config/schema.py +87 -4
  152. package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
  153. package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
  154. package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
  155. package/src/superlocalmemory/optimize/storage/db.py +38 -9
  156. package/src/superlocalmemory/optimize/storage/schema.py +10 -0
  157. package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
  158. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  159. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
  160. package/src/superlocalmemory/retrieval/engine.py +168 -26
  161. package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
  162. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
  163. package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
  164. package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
  165. package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
  166. package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
  167. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
  168. package/src/superlocalmemory/retrieval/time_window.py +181 -0
  169. package/src/superlocalmemory/server/api.py +4 -4
  170. package/src/superlocalmemory/server/profile_runtime.py +125 -8
  171. package/src/superlocalmemory/server/rbac_enforce.py +142 -0
  172. package/src/superlocalmemory/server/recall_health.py +24 -3
  173. package/src/superlocalmemory/server/recall_serializer.py +19 -1
  174. package/src/superlocalmemory/server/routes/abstraction.py +115 -0
  175. package/src/superlocalmemory/server/routes/agents.py +128 -38
  176. package/src/superlocalmemory/server/routes/backup.py +34 -10
  177. package/src/superlocalmemory/server/routes/behavioral.py +13 -12
  178. package/src/superlocalmemory/server/routes/brain.py +21 -5
  179. package/src/superlocalmemory/server/routes/chat.py +10 -5
  180. package/src/superlocalmemory/server/routes/compliance.py +171 -21
  181. package/src/superlocalmemory/server/routes/config_api.py +436 -0
  182. package/src/superlocalmemory/server/routes/data_io.py +30 -8
  183. package/src/superlocalmemory/server/routes/entity.py +9 -4
  184. package/src/superlocalmemory/server/routes/events.py +24 -8
  185. package/src/superlocalmemory/server/routes/evolution.py +135 -17
  186. package/src/superlocalmemory/server/routes/helpers.py +16 -1
  187. package/src/superlocalmemory/server/routes/ingest.py +7 -4
  188. package/src/superlocalmemory/server/routes/insights.py +3 -3
  189. package/src/superlocalmemory/server/routes/learning.py +14 -14
  190. package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
  191. package/src/superlocalmemory/server/routes/memories.py +182 -57
  192. package/src/superlocalmemory/server/routes/mesh.py +95 -15
  193. package/src/superlocalmemory/server/routes/optimize.py +33 -1
  194. package/src/superlocalmemory/server/routes/prewarm.py +2 -0
  195. package/src/superlocalmemory/server/routes/profiles.py +63 -17
  196. package/src/superlocalmemory/server/routes/ratelimit.py +124 -0
  197. package/src/superlocalmemory/server/routes/rbac.py +367 -0
  198. package/src/superlocalmemory/server/routes/stats.py +13 -6
  199. package/src/superlocalmemory/server/routes/tiers.py +11 -9
  200. package/src/superlocalmemory/server/routes/v3_api.py +183 -69
  201. package/src/superlocalmemory/server/routes/ws.py +5 -2
  202. package/src/superlocalmemory/server/security_middleware.py +12 -5
  203. package/src/superlocalmemory/server/ui.py +20 -5
  204. package/src/superlocalmemory/server/unified_daemon.py +384 -56
  205. package/src/superlocalmemory/server/write_identity.py +38 -8
  206. package/src/superlocalmemory/storage/database.py +265 -53
  207. package/src/superlocalmemory/storage/migration_runner.py +53 -0
  208. package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
  209. package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
  210. package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
  211. package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
  212. package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
  213. package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
  214. package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
  215. package/src/superlocalmemory/storage/models.py +4 -0
  216. package/src/superlocalmemory/storage/schema.py +87 -0
  217. package/src/superlocalmemory/storage/schema_v343.py +24 -12
  218. package/src/superlocalmemory/trust/gate.py +49 -8
  219. package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
  220. package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
  221. package/src/superlocalmemory/ui/css/design-system.css +621 -0
  222. package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
  223. package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
  224. package/src/superlocalmemory/ui/favicon.svg +35 -4
  225. package/src/superlocalmemory/ui/index.html +306 -173
  226. package/src/superlocalmemory/ui/js/brain.js +5 -20
  227. package/src/superlocalmemory/ui/js/core.js +47 -31
  228. package/src/superlocalmemory/ui/js/dashboard.js +314 -63
  229. package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
  230. package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
  231. package/src/superlocalmemory/ui/js/math-health.js +1 -1
  232. package/src/superlocalmemory/ui/js/memories.js +15 -4
  233. package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
  234. package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
  235. package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
  236. package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
  237. package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
  238. package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
  239. package/src/superlocalmemory/ui/js/od-agents.js +544 -0
  240. package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
  241. package/src/superlocalmemory/ui/js/od-backup.js +780 -0
  242. package/src/superlocalmemory/ui/js/od-brain.js +779 -0
  243. package/src/superlocalmemory/ui/js/od-entities.js +579 -0
  244. package/src/superlocalmemory/ui/js/od-graph.js +593 -0
  245. package/src/superlocalmemory/ui/js/od-health.js +539 -0
  246. package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
  247. package/src/superlocalmemory/ui/js/od-memories.js +887 -0
  248. package/src/superlocalmemory/ui/js/od-mesh.js +539 -0
  249. package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
  250. package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
  251. package/src/superlocalmemory/ui/js/od-settings.js +1053 -0
  252. package/src/superlocalmemory/ui/js/od-shell.js +593 -0
  253. package/src/superlocalmemory/ui/js/od-skills.js +573 -0
  254. package/src/superlocalmemory/ui/js/od-team.js +258 -0
  255. package/src/superlocalmemory/ui/js/profiles.js +159 -46
  256. package/src/superlocalmemory/ui/js/settings.js +2 -2
  257. package/src/superlocalmemory/ui/js/timeline.js +34 -5
  258. package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
  259. package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
  260. package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
@@ -0,0 +1,136 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 — RBAC hardening (SEC-H-01, audit-02)
4
+
5
+ """M026 — add FK on rbac_memberships → profiles ON DELETE CASCADE (memory.db).
6
+
7
+ SEC-H-01: ``rbac_memberships`` (created by M024) had no foreign key to
8
+ ``profiles``. Deleting a profile therefore left its role grants behind; a
9
+ profile later recreated with the same ``profile_id`` silently re-inherited
10
+ them — a privilege-persistence bug.
11
+
12
+ The runtime path (``delete_profile_from_db``) already purges memberships
13
+ explicitly. This migration is the *defense-in-depth* half: a real FK with
14
+ ``ON DELETE CASCADE`` so that ANY path deleting a ``profiles`` row — not just
15
+ that one helper — cannot leave orphaned grants.
16
+
17
+ SQLite cannot add a constraint in place, so the table is rebuilt (the
18
+ recommended safe-alter dance): rename → create canonical table with the FK →
19
+ copy rows → drop old → recreate the user index. Wrapped in BEGIN IMMEDIATE /
20
+ COMMIT for atomicity; the runner connects with ``foreign_keys = OFF`` so the
21
+ rename/copy/drop never trips a constraint action mid-rebuild.
22
+
23
+ Idempotent: skips when the FK is already present (fresh installs still get the
24
+ FK because this always rebuilds M024's FK-less table on first run). Tolerant of
25
+ a missing table (nothing to migrate).
26
+
27
+ Deferred because the FK target ``profiles`` is created at engine init
28
+ (``storage.schema``), after ``apply_all`` runs — same ordering constraint as
29
+ M021 / M023.
30
+
31
+ Author: Varun Pratap Bhardwaj / Qualixar
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import sqlite3
37
+
38
+ NAME = "M026_rbac_memberships_fk"
39
+ DB_TARGET = "memory"
40
+
41
+ # Documentation + drift-hash fingerprint. apply() below is the authoritative
42
+ # executor (a static DDL string cannot express the conditional rebuild).
43
+ DDL = (
44
+ "-- rebuild rbac_memberships with "
45
+ "FOREIGN KEY (profile_id) REFERENCES profiles(profile_id) ON DELETE CASCADE"
46
+ )
47
+
48
+ # Canonical rebuilt table. Column set is byte-identical to M024's, plus the FK.
49
+ # The index is created separately AFTER the old table is dropped: RENAME keeps
50
+ # the old index's name, so an inline/early CREATE INDEX would collide.
51
+ _NEW_TABLE = """
52
+ CREATE TABLE rbac_memberships (
53
+ profile_id TEXT NOT NULL,
54
+ user_id TEXT NOT NULL,
55
+ role TEXT NOT NULL,
56
+ added_at TEXT NOT NULL,
57
+ added_by TEXT DEFAULT 'owner',
58
+ PRIMARY KEY (profile_id, user_id),
59
+ FOREIGN KEY (profile_id) REFERENCES profiles(profile_id) ON DELETE CASCADE
60
+ )
61
+ """
62
+
63
+
64
+ def _table_exists(conn: sqlite3.Connection, table: str) -> bool:
65
+ return conn.execute(
66
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)
67
+ ).fetchone() is not None
68
+
69
+
70
+ def _cols(conn: sqlite3.Connection, table: str) -> set[str]:
71
+ return {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
72
+
73
+
74
+ def _has_profiles_fk(conn: sqlite3.Connection, table: str) -> bool:
75
+ """True when ``table`` already declares a foreign key onto ``profiles``."""
76
+ try:
77
+ rows = conn.execute(f"PRAGMA foreign_key_list({table})").fetchall()
78
+ except sqlite3.OperationalError: # pragma: no cover — table absent
79
+ return False
80
+ # PRAGMA foreign_key_list row layout: (id, seq, table, from, to, ...).
81
+ return any(r[2] == "profiles" for r in rows)
82
+
83
+
84
+ def apply(conn: sqlite3.Connection) -> None:
85
+ """Rebuild rbac_memberships with the profiles FK.
86
+
87
+ No-op when the table is absent (nothing to migrate) or the FK already
88
+ exists (idempotent re-run). Existing grants are preserved verbatim.
89
+ """
90
+ if not _table_exists(conn, "rbac_memberships"):
91
+ return
92
+ if _has_profiles_fk(conn, "rbac_memberships"):
93
+ return
94
+
95
+ has_added_by = "added_by" in _cols(conn, "rbac_memberships")
96
+
97
+ conn.execute("BEGIN IMMEDIATE")
98
+ try:
99
+ conn.execute("ALTER TABLE rbac_memberships RENAME TO _rbac_memberships_old")
100
+ # NB: single statement via execute(), not executescript() — the latter
101
+ # force-commits the pending transaction, breaking our BEGIN/COMMIT.
102
+ conn.execute(_NEW_TABLE)
103
+ if has_added_by:
104
+ conn.execute(
105
+ "INSERT INTO rbac_memberships "
106
+ "(profile_id, user_id, role, added_at, added_by) "
107
+ "SELECT profile_id, user_id, role, added_at, added_by "
108
+ "FROM _rbac_memberships_old"
109
+ )
110
+ else: # exotic/older install missing the column — default it
111
+ conn.execute(
112
+ "INSERT INTO rbac_memberships "
113
+ "(profile_id, user_id, role, added_at) "
114
+ "SELECT profile_id, user_id, role, added_at "
115
+ "FROM _rbac_memberships_old"
116
+ )
117
+ # Dropping the old table also drops its index, freeing the name.
118
+ conn.execute("DROP TABLE _rbac_memberships_old")
119
+ conn.execute(
120
+ "CREATE INDEX IF NOT EXISTS idx_rbac_memberships_user "
121
+ "ON rbac_memberships(user_id)"
122
+ )
123
+ conn.execute("COMMIT")
124
+ except sqlite3.Error:
125
+ try:
126
+ conn.execute("ROLLBACK")
127
+ except sqlite3.Error: # pragma: no cover — best-effort
128
+ pass
129
+ raise
130
+
131
+
132
+ def verify(conn: sqlite3.Connection) -> bool:
133
+ """Applied once rbac_memberships carries the profiles FK (or is absent)."""
134
+ if not _table_exists(conn, "rbac_memberships"):
135
+ return True # nothing to migrate; fresh install rebuilds it on apply
136
+ return _has_profiles_fk(conn, "rbac_memberships")
@@ -0,0 +1,163 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 — isolation correctness (H-01, cycle-3 audit)
4
+
5
+ """M027 — add profile_id to transferable_patterns + rebuild UNIQUE constraint (learning.db).
6
+
7
+ H-01: ``transferable_patterns`` had no ``profile_id`` column; every profile's
8
+ consolidation wrote to the same (pattern_type, key) row. The last writer won,
9
+ so any profile's ``get_preferences()`` call returned patterns contaminated by
10
+ other profiles' consolidation runs.
11
+
12
+ Fix:
13
+ 1. Add ``profile_id TEXT NOT NULL DEFAULT 'default'`` to the table.
14
+ 2. Change the UNIQUE constraint from ``(pattern_type, key)`` to
15
+ ``(profile_id, pattern_type, key)`` so each profile owns its own rows.
16
+ 3. Backfill existing rows to ``profile_id = 'default'`` (the only valid value
17
+ before this migration — they are legacy/unattributed patterns).
18
+
19
+ SQLite cannot ALTER a UNIQUE constraint in place; the table must be rebuilt
20
+ (rename → create canonical form → copy → drop old). Wrapped in
21
+ ``BEGIN IMMEDIATE / COMMIT`` for atomicity.
22
+
23
+ Idempotent:
24
+ * If ``profile_id`` already exists with the correct UNIQUE constraint
25
+ (fresh install after the code change), ``apply()`` is a no-op.
26
+ * If the table is absent entirely (CrossProjectAggregator was never run
27
+ against learning.db), ``apply()`` is a no-op — the new _SCHEMA in
28
+ cross_project.py will create it in its canonical form on first use.
29
+
30
+ DB target: ``learning`` — CrossProjectAggregator is under the learning/
31
+ package and is expected to share the learning database.
32
+
33
+ Author: Varun Pratap Bhardwaj / Qualixar
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import sqlite3
39
+
40
+ NAME = "M027_transferable_patterns_profile"
41
+ DB_TARGET = "learning"
42
+
43
+ DDL = (
44
+ "-- rebuild transferable_patterns with "
45
+ "profile_id TEXT NOT NULL DEFAULT 'default' "
46
+ "and UNIQUE(profile_id, pattern_type, key)"
47
+ )
48
+
49
+ _NEW_TABLE = """
50
+ CREATE TABLE transferable_patterns (
51
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
52
+ profile_id TEXT NOT NULL DEFAULT 'default',
53
+ pattern_type TEXT NOT NULL DEFAULT 'preference',
54
+ key TEXT NOT NULL,
55
+ value TEXT NOT NULL,
56
+ confidence REAL DEFAULT 0.0,
57
+ evidence_count INTEGER DEFAULT 0,
58
+ profiles_seen INTEGER DEFAULT 1,
59
+ decay_factor REAL DEFAULT 1.0,
60
+ contradictions TEXT DEFAULT '[]',
61
+ first_seen TEXT,
62
+ last_seen TEXT,
63
+ UNIQUE(profile_id, pattern_type, key)
64
+ )
65
+ """
66
+
67
+
68
+ def _table_exists(conn: sqlite3.Connection, table: str) -> bool:
69
+ return conn.execute(
70
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)
71
+ ).fetchone() is not None
72
+
73
+
74
+ def _cols(conn: sqlite3.Connection, table: str) -> set[str]:
75
+ return {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
76
+
77
+
78
+ def _unique_index_covers_profile(conn: sqlite3.Connection) -> bool:
79
+ """True when transferable_patterns already has the UNIQUE(profile_id, …) constraint."""
80
+ indexes = conn.execute(
81
+ "SELECT name, sql FROM sqlite_master "
82
+ "WHERE type='index' AND tbl_name='transferable_patterns'"
83
+ ).fetchall()
84
+ for idx in indexes:
85
+ sql = (idx[1] or "").lower()
86
+ if "profile_id" in sql and "pattern_type" in sql and "key" in sql:
87
+ return True
88
+ # SQLite stores the UNIQUE constraint on the CREATE TABLE statement as well.
89
+ tbl_sql = conn.execute(
90
+ "SELECT sql FROM sqlite_master "
91
+ "WHERE type='table' AND name='transferable_patterns'"
92
+ ).fetchone()
93
+ if tbl_sql:
94
+ sql = (tbl_sql[0] or "").lower()
95
+ if "unique(profile_id" in sql or "unique (profile_id" in sql:
96
+ return True
97
+ return False
98
+
99
+
100
+ def apply(conn: sqlite3.Connection) -> None:
101
+ """Rebuild transferable_patterns with profile_id column and correct UNIQUE constraint.
102
+
103
+ No-op when the table is absent or already in canonical form.
104
+ Existing data is preserved; legacy rows get profile_id = 'default'.
105
+ """
106
+ if not _table_exists(conn, "transferable_patterns"):
107
+ return # Table was never created against this DB — fresh install handles it.
108
+
109
+ if (
110
+ "profile_id" in _cols(conn, "transferable_patterns")
111
+ and _unique_index_covers_profile(conn)
112
+ ):
113
+ return # Already in canonical form.
114
+
115
+ has_profile_col = "profile_id" in _cols(conn, "transferable_patterns")
116
+
117
+ conn.execute("BEGIN IMMEDIATE")
118
+ try:
119
+ conn.execute(
120
+ "ALTER TABLE transferable_patterns RENAME TO _tp_old"
121
+ )
122
+ conn.execute(_NEW_TABLE)
123
+
124
+ if has_profile_col:
125
+ # Column exists but UNIQUE constraint is wrong — copy as-is.
126
+ conn.execute(
127
+ "INSERT INTO transferable_patterns "
128
+ "(profile_id, pattern_type, key, value, confidence, evidence_count, "
129
+ " profiles_seen, decay_factor, contradictions, first_seen, last_seen) "
130
+ "SELECT profile_id, pattern_type, key, value, confidence, evidence_count, "
131
+ " profiles_seen, decay_factor, contradictions, first_seen, last_seen "
132
+ "FROM _tp_old"
133
+ )
134
+ else:
135
+ # No profile_id yet — backfill legacy rows to 'default'.
136
+ conn.execute(
137
+ "INSERT INTO transferable_patterns "
138
+ "(profile_id, pattern_type, key, value, confidence, evidence_count, "
139
+ " profiles_seen, decay_factor, contradictions, first_seen, last_seen) "
140
+ "SELECT 'default', pattern_type, key, value, confidence, evidence_count, "
141
+ " profiles_seen, decay_factor, contradictions, first_seen, last_seen "
142
+ "FROM _tp_old"
143
+ )
144
+
145
+ conn.execute("DROP TABLE _tp_old")
146
+ conn.execute("COMMIT")
147
+ except sqlite3.Error:
148
+ try:
149
+ conn.execute("ROLLBACK")
150
+ except sqlite3.Error:
151
+ pass
152
+ raise
153
+
154
+
155
+ def verify(conn: sqlite3.Connection) -> bool:
156
+ """True when transferable_patterns has profile_id with the correct UNIQUE,
157
+ or when the table is absent (fresh install will create it correctly).
158
+ """
159
+ if not _table_exists(conn, "transferable_patterns"):
160
+ return True # absent — first run of _ensure_schema creates canonical form
161
+ if "profile_id" not in _cols(conn, "transferable_patterns"):
162
+ return False
163
+ return _unique_index_covers_profile(conn)
@@ -440,3 +440,7 @@ class RecallResponse:
440
440
  # be mistaken for a reranked response.
441
441
  reranker_applied: bool = False
442
442
  reranker_status: str = "not_configured"
443
+ # Wave Q2b: precomputed community summary for the cluster the top results
444
+ # fall into (thematic context). None unless results cluster into one
445
+ # community above threshold. Additive — backward compatible.
446
+ community_context: dict | None = None
@@ -52,10 +52,14 @@ _TABLES: Final[tuple[str, ...]] = (
52
52
  "compliance_audit",
53
53
  "bm25_tokens",
54
54
  "config",
55
+ "entity_communities",
56
+ "community_summaries",
57
+ "persona_summary",
55
58
  )
56
59
 
57
60
  _FTS_TABLES: Final[tuple[str, ...]] = (
58
61
  "atomic_facts_fts",
62
+ "fact_expansion_fts",
59
63
  )
60
64
 
61
65
 
@@ -281,6 +285,24 @@ END;
281
285
  """
282
286
 
283
287
 
288
+ # ---------------------------------------------------------------------------
289
+ # Fact expansion FTS (Phase 4, T3b — fact-augmented key expansion)
290
+ # ---------------------------------------------------------------------------
291
+ # Standalone (NOT external-content) FTS5 holding per-fact alternate keys
292
+ # (entity aliases + paraphrases). Kept separate from atomic_facts_fts so the
293
+ # proven content index and its triggers are never touched. Populated by
294
+ # core.key_expander on store (and backfilled), queried as an additive UNION in
295
+ # the BM25 channel. fact_id is stored so results can be scope-JOINed to
296
+ # atomic_facts.
297
+ _SQL_FACT_EXPANSION_FTS: Final[str] = """
298
+ CREATE VIRTUAL TABLE IF NOT EXISTS fact_expansion_fts
299
+ USING fts5(
300
+ fact_id UNINDEXED,
301
+ alt_keys
302
+ );
303
+ """
304
+
305
+
284
306
  # ---------------------------------------------------------------------------
285
307
  # Canonical entities
286
308
  # ---------------------------------------------------------------------------
@@ -316,6 +338,7 @@ CREATE INDEX IF NOT EXISTS idx_entities_type
316
338
  _SQL_ENTITY_ALIASES: Final[str] = """
317
339
  CREATE TABLE IF NOT EXISTS entity_aliases (
318
340
  alias_id TEXT PRIMARY KEY,
341
+ profile_id TEXT NOT NULL DEFAULT 'default',
319
342
  entity_id TEXT NOT NULL,
320
343
  alias TEXT NOT NULL,
321
344
  confidence REAL NOT NULL DEFAULT 1.0,
@@ -329,6 +352,10 @@ CREATE INDEX IF NOT EXISTS idx_aliases_entity
329
352
  ON entity_aliases (entity_id);
330
353
  CREATE INDEX IF NOT EXISTS idx_aliases_lookup
331
354
  ON entity_aliases (alias COLLATE NOCASE);
355
+ -- NOTE: the (profile_id, entity_id) index is created by migration M022, NOT
356
+ -- here. On an upgrading DB this DDL runs at engine init BEFORE the deferred
357
+ -- M022 adds the profile_id column, so referencing it here would fail engine
358
+ -- init with "no such column: profile_id".
332
359
  """
333
360
 
334
361
 
@@ -663,6 +690,58 @@ CREATE TABLE IF NOT EXISTS config (
663
690
  );
664
691
  """
665
692
 
693
+ # Wave Q: entity-community backbone (additive; safe on existing DBs).
694
+ # One row per (profile, entity) mapping to the community it belongs to,
695
+ # computed by Louvain over the entity co-occurrence graph in the background.
696
+ # Shared spine for Q2 community summaries and Q3 progressive abstraction.
697
+ _SQL_ENTITY_COMMUNITIES: Final[str] = """
698
+ CREATE TABLE IF NOT EXISTS entity_communities (
699
+ profile_id TEXT NOT NULL,
700
+ entity_id TEXT NOT NULL,
701
+ community_id INTEGER NOT NULL,
702
+ computed_at TEXT NOT NULL DEFAULT (datetime('now')),
703
+ PRIMARY KEY (profile_id, entity_id)
704
+ );
705
+ CREATE INDEX IF NOT EXISTS idx_entity_comm_profile
706
+ ON entity_communities(profile_id);
707
+ CREATE INDEX IF NOT EXISTS idx_entity_comm_cid
708
+ ON entity_communities(profile_id, community_id);
709
+ """
710
+
711
+ # Wave Q2: one synthesized report per entity community (additive; safe on
712
+ # existing DBs). Generated in the background after entity_communities; the
713
+ # summary excludes superseded facts. member_fact_ids enables drill-down.
714
+ _SQL_COMMUNITY_SUMMARIES: Final[str] = """
715
+ CREATE TABLE IF NOT EXISTS community_summaries (
716
+ profile_id TEXT NOT NULL,
717
+ community_id INTEGER NOT NULL,
718
+ summary TEXT NOT NULL DEFAULT '',
719
+ keywords TEXT NOT NULL DEFAULT '',
720
+ entity_ids_json TEXT NOT NULL DEFAULT '[]',
721
+ fact_ids_json TEXT NOT NULL DEFAULT '[]',
722
+ fact_count INTEGER NOT NULL DEFAULT 0,
723
+ computed_at TEXT NOT NULL DEFAULT (datetime('now')),
724
+ PRIMARY KEY (profile_id, community_id)
725
+ );
726
+ CREATE INDEX IF NOT EXISTS idx_comm_summ_profile
727
+ ON community_summaries(profile_id);
728
+ """
729
+
730
+ # Wave Q3: progressive-abstraction top tier — one persona roll-up per profile
731
+ # consuming the top community summaries (additive; safe on existing DBs).
732
+ # Recall-gated (never auto-injected into hot recall) and size-bounded to avoid
733
+ # the V3.4.40 summary-pollution regression. Drill-down: community_ids_json →
734
+ # communities → their member facts.
735
+ _SQL_PERSONA_SUMMARY: Final[str] = """
736
+ CREATE TABLE IF NOT EXISTS persona_summary (
737
+ profile_id TEXT PRIMARY KEY,
738
+ summary TEXT NOT NULL DEFAULT '',
739
+ keywords TEXT NOT NULL DEFAULT '',
740
+ community_ids_json TEXT NOT NULL DEFAULT '[]',
741
+ computed_at TEXT NOT NULL DEFAULT (datetime('now'))
742
+ );
743
+ """
744
+
666
745
  # ---------------------------------------------------------------------------
667
746
  # Ordered DDL list (tables before FTS, respects FK order)
668
747
  # ---------------------------------------------------------------------------
@@ -691,6 +770,14 @@ _DDL_ORDERED: Final[tuple[str, ...]] = (
691
770
  _SQL_V2_MIGRATION_CLEANUP,
692
771
  # FTS5 must come after atomic_facts (content table) AND after cleanup
693
772
  _SQL_ATOMIC_FACTS_FTS,
773
+ # T3b: standalone expansion FTS (additive; safe on existing DBs)
774
+ _SQL_FACT_EXPANSION_FTS,
775
+ # Wave Q: entity-community backbone (additive; safe on existing DBs)
776
+ _SQL_ENTITY_COMMUNITIES,
777
+ # Wave Q2: community summaries (additive; safe on existing DBs)
778
+ _SQL_COMMUNITY_SUMMARIES,
779
+ # Wave Q3: persona roll-up tier (additive; safe on existing DBs)
780
+ _SQL_PERSONA_SUMMARY,
694
781
  )
695
782
 
696
783
 
@@ -43,7 +43,7 @@ V343_TABLES: Final[tuple[str, ...]] = (
43
43
  # ---------------------------------------------------------------------------
44
44
 
45
45
  _MESH_DDL = """
46
- -- Mesh Peers
46
+ -- Mesh Peers (profile_id = tenant boundary; see M023)
47
47
  CREATE TABLE IF NOT EXISTS mesh_peers (
48
48
  peer_id TEXT PRIMARY KEY,
49
49
  session_id TEXT NOT NULL,
@@ -52,7 +52,8 @@ CREATE TABLE IF NOT EXISTS mesh_peers (
52
52
  host TEXT DEFAULT '127.0.0.1',
53
53
  port INTEGER DEFAULT 0,
54
54
  registered_at TEXT NOT NULL,
55
- last_heartbeat TEXT NOT NULL
55
+ last_heartbeat TEXT NOT NULL,
56
+ profile_id TEXT NOT NULL DEFAULT 'default'
56
57
  );
57
58
 
58
59
  -- Mesh Messages
@@ -63,23 +64,28 @@ CREATE TABLE IF NOT EXISTS mesh_messages (
63
64
  msg_type TEXT DEFAULT 'text',
64
65
  content TEXT NOT NULL,
65
66
  read INTEGER DEFAULT 0,
66
- created_at TEXT NOT NULL
67
+ created_at TEXT NOT NULL,
68
+ profile_id TEXT NOT NULL DEFAULT 'default'
67
69
  );
68
70
 
69
- -- Mesh State (shared key-value store)
71
+ -- Mesh State (shared key-value store, per tenant)
70
72
  CREATE TABLE IF NOT EXISTS mesh_state (
71
- key TEXT PRIMARY KEY,
73
+ profile_id TEXT NOT NULL DEFAULT 'default',
74
+ key TEXT NOT NULL,
72
75
  value TEXT NOT NULL,
73
76
  set_by TEXT NOT NULL,
74
- updated_at TEXT NOT NULL
77
+ updated_at TEXT NOT NULL,
78
+ PRIMARY KEY (profile_id, key)
75
79
  );
76
80
 
77
- -- Mesh Locks (file-level locks for coordination)
81
+ -- Mesh Locks (file-level locks for coordination, per tenant)
78
82
  CREATE TABLE IF NOT EXISTS mesh_locks (
79
- file_path TEXT PRIMARY KEY,
83
+ profile_id TEXT NOT NULL DEFAULT 'default',
84
+ file_path TEXT NOT NULL,
80
85
  locked_by TEXT NOT NULL,
81
86
  locked_at TEXT NOT NULL,
82
- expires_at TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'
87
+ expires_at TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z',
88
+ PRIMARY KEY (profile_id, file_path)
83
89
  );
84
90
 
85
91
  -- Mesh Events (audit log)
@@ -88,9 +94,14 @@ CREATE TABLE IF NOT EXISTS mesh_events (
88
94
  event_type TEXT NOT NULL,
89
95
  payload TEXT DEFAULT '{}',
90
96
  emitted_by TEXT NOT NULL,
91
- created_at TEXT NOT NULL
97
+ created_at TEXT NOT NULL,
98
+ profile_id TEXT NOT NULL DEFAULT 'default'
92
99
  );
93
100
 
101
+ -- NOTE: profile-leading indexes live in migration M023, never here — on an
102
+ -- upgrade the profile_id column does not exist when this DDL runs (the old
103
+ -- table already exists, so CREATE TABLE IF NOT EXISTS no-ops), and a
104
+ -- CREATE INDEX on the missing column would abort engine init.
94
105
  CREATE INDEX IF NOT EXISTS idx_mesh_messages_to
95
106
  ON mesh_messages(to_peer, read);
96
107
  CREATE INDEX IF NOT EXISTS idx_mesh_events_type
@@ -123,16 +134,17 @@ CREATE INDEX IF NOT EXISTS idx_entity_profiles_project
123
134
  _INGESTION_DDL = """
124
135
  CREATE TABLE IF NOT EXISTS ingestion_log (
125
136
  id INTEGER PRIMARY KEY AUTOINCREMENT,
137
+ profile_id TEXT NOT NULL DEFAULT 'default',
126
138
  source_type TEXT NOT NULL,
127
139
  dedup_key TEXT NOT NULL,
128
140
  fact_ids TEXT DEFAULT '[]',
129
141
  metadata TEXT DEFAULT '{}',
130
142
  status TEXT DEFAULT 'ingested',
131
143
  ingested_at TEXT NOT NULL,
132
- UNIQUE(source_type, dedup_key)
144
+ UNIQUE(profile_id, source_type, dedup_key)
133
145
  );
134
146
  CREATE INDEX IF NOT EXISTS idx_ingestion_dedup
135
- ON ingestion_log(source_type, dedup_key);
147
+ ON ingestion_log(profile_id, source_type, dedup_key);
136
148
  """
137
149
 
138
150
  # ---------------------------------------------------------------------------
@@ -13,6 +13,7 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
13
13
  from __future__ import annotations
14
14
 
15
15
  import logging
16
+ import os
16
17
  from typing import TYPE_CHECKING
17
18
 
18
19
  if TYPE_CHECKING:
@@ -21,6 +22,26 @@ if TYPE_CHECKING:
21
22
  logger = logging.getLogger(__name__)
22
23
 
23
24
 
25
+ def _env_threshold(name: str, default: float) -> float:
26
+ """Read a [0,1] threshold from the environment, falling back on parse/range error."""
27
+ try:
28
+ value = float(os.environ.get(name, ""))
29
+ except (TypeError, ValueError):
30
+ return default
31
+ return value if 0.0 <= value <= 1.0 else default
32
+
33
+
34
+ # M-02/M-03 (3.7.9): thresholds are operator-tunable so multi-agent deployments
35
+ # can tighten the write gate and opt into a read gate. Defaults are unchanged —
36
+ # the audit's "raise write_threshold to 0.7" would block every new agent (a new
37
+ # agent scores 0.5, Beta(1,1)); loopback callers bypass the gate entirely, so
38
+ # this only affects remote credentialed agents. Read gate is OFF by default.
39
+ _DEFAULT_WRITE_THRESHOLD = _env_threshold("SLM_TRUST_WRITE_THRESHOLD", 0.3)
40
+ _DEFAULT_DELETE_THRESHOLD = _env_threshold("SLM_TRUST_DELETE_THRESHOLD", 0.5)
41
+ _DEFAULT_READ_THRESHOLD = _env_threshold("SLM_TRUST_READ_THRESHOLD", 0.1)
42
+ _READ_GATE_ENABLED = os.environ.get("SLM_TRUST_READ_GATE") == "1"
43
+
44
+
24
45
  class TrustError(PermissionError):
25
46
  """Raised when an agent fails a trust check.
26
47
 
@@ -62,17 +83,23 @@ class TrustGate:
62
83
  def __init__(
63
84
  self,
64
85
  scorer: TrustScorer,
65
- write_threshold: float = 0.3,
66
- delete_threshold: float = 0.5,
86
+ write_threshold: float = _DEFAULT_WRITE_THRESHOLD,
87
+ delete_threshold: float = _DEFAULT_DELETE_THRESHOLD,
88
+ read_threshold: float = _DEFAULT_READ_THRESHOLD,
89
+ read_gate_enabled: bool = _READ_GATE_ENABLED,
67
90
  ) -> None:
68
91
  if write_threshold < 0 or write_threshold > 1:
69
92
  raise ValueError("write_threshold must be in [0, 1]")
70
93
  if delete_threshold < 0 or delete_threshold > 1:
71
94
  raise ValueError("delete_threshold must be in [0, 1]")
95
+ if read_threshold < 0 or read_threshold > 1:
96
+ raise ValueError("read_threshold must be in [0, 1]")
72
97
 
73
98
  self._scorer = scorer
74
99
  self._write_threshold = write_threshold
75
100
  self._delete_threshold = delete_threshold
101
+ self._read_threshold = read_threshold
102
+ self._read_gate_enabled = read_gate_enabled
76
103
 
77
104
  @property
78
105
  def write_threshold(self) -> float:
@@ -116,15 +143,29 @@ class TrustGate:
116
143
  agent_id, score, self._delete_threshold, "delete"
117
144
  )
118
145
 
146
+ @property
147
+ def read_threshold(self) -> float:
148
+ return self._read_threshold
149
+
150
+ @property
151
+ def read_gate_enabled(self) -> bool:
152
+ return self._read_gate_enabled
153
+
119
154
  def check_read(self, agent_id: str, profile_id: str) -> None:
120
- """Read check always passes. Logged for audit trail.
155
+ """Read check. Passes by default; logged for the audit trail.
121
156
 
122
- Reads are never blocked because denying read access could break
123
- agent functionality. However, logging read access enables
124
- anomaly detection and compliance auditing.
157
+ M-03 (3.7.9): reads are unblocked by default because denying read access
158
+ could break agent functionality. Operators who need to stop a
159
+ compromised agent from exfiltrating the store can set
160
+ ``SLM_TRUST_READ_GATE=1`` (optionally with ``SLM_TRUST_READ_THRESHOLD``)
161
+ to enforce a minimum trust for reads too.
125
162
  """
126
163
  score = self._scorer.get_agent_trust(agent_id, profile_id)
127
164
  logger.debug(
128
- "trust gate read (always pass): agent=%s trust=%.3f",
129
- agent_id, score,
165
+ "trust gate read: agent=%s trust=%.3f gate=%s",
166
+ agent_id, score, self._read_gate_enabled,
130
167
  )
168
+ if self._read_gate_enabled and score < self._read_threshold:
169
+ raise TrustError(
170
+ agent_id, score, self._read_threshold, "read"
171
+ )