ltcai 11.1.0 → 11.4.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 (289) hide show
  1. package/README.md +47 -54
  2. package/docs/CHANGELOG.md +94 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/FEATURE_AUDIT_v11.2.0.md +393 -0
  6. package/docs/LAYOUT_REBUILD_SPEC.md +9 -1
  7. package/docs/MULTI_AGENT_RUNTIME.md +1 -1
  8. package/docs/ONBOARDING.md +1 -1
  9. package/docs/OPERATIONS.md +6 -2
  10. package/docs/PERMISSION_MODE.md +1 -1
  11. package/docs/TRUST_MODEL.md +1 -1
  12. package/docs/WHY_LATTICE.md +1 -1
  13. package/docs/architecture.md +6 -2
  14. package/docs/kg-schema.md +2 -2
  15. package/docs/v11.3.0_PLAN.md +202 -0
  16. package/docs/v11.4.0_RUST_FOUNDATION_PLAN.md +176 -0
  17. package/lattice_brain/__init__.py +1 -1
  18. package/lattice_brain/gates.py +125 -0
  19. package/lattice_brain/graph/_kg_common/__init__.py +287 -0
  20. package/lattice_brain/graph/_kg_common/extraction.py +516 -0
  21. package/lattice_brain/graph/_kg_common/relations.py +161 -0
  22. package/lattice_brain/graph/_kg_common/text.py +479 -0
  23. package/lattice_brain/graph/discovery_index/__init__.py +35 -0
  24. package/lattice_brain/graph/discovery_index/cleanup.py +182 -0
  25. package/lattice_brain/graph/discovery_index/extract.py +137 -0
  26. package/lattice_brain/graph/discovery_index/scan.py +411 -0
  27. package/lattice_brain/graph/discovery_index/upsert.py +495 -0
  28. package/lattice_brain/graph/fusion.py +35 -4
  29. package/lattice_brain/graph/projection/__init__.py +42 -0
  30. package/lattice_brain/graph/projection/curation.py +500 -0
  31. package/lattice_brain/graph/{projection.py → projection/v2_schema.py} +81 -485
  32. package/lattice_brain/graph/retrieval/__init__.py +54 -0
  33. package/lattice_brain/graph/retrieval/context.py +197 -0
  34. package/lattice_brain/graph/retrieval/graph_view.py +319 -0
  35. package/lattice_brain/graph/retrieval/hybrid.py +488 -0
  36. package/lattice_brain/graph/retrieval/maintenance.py +121 -0
  37. package/lattice_brain/graph/retrieval/signals.py +95 -0
  38. package/lattice_brain/graph/retrieval_vector/__init__.py +42 -0
  39. package/lattice_brain/graph/retrieval_vector/fingerprint.py +97 -0
  40. package/lattice_brain/graph/retrieval_vector/indexing.py +347 -0
  41. package/lattice_brain/graph/retrieval_vector/search.py +560 -0
  42. package/lattice_brain/graph/retrieval_vector/status.py +374 -0
  43. package/lattice_brain/graph/schema.py +9 -0
  44. package/lattice_brain/graph/store.py +9 -0
  45. package/lattice_brain/graph/vector_index/selector.py +32 -2
  46. package/lattice_brain/ingestion/__init__.py +130 -0
  47. package/lattice_brain/ingestion/_contract.py +90 -0
  48. package/lattice_brain/ingestion/constants.py +127 -0
  49. package/lattice_brain/ingestion/folder_scan.py +57 -0
  50. package/lattice_brain/ingestion/folders.py +258 -0
  51. package/lattice_brain/ingestion/hashing.py +26 -0
  52. package/lattice_brain/ingestion/jobs_api.py +107 -0
  53. package/lattice_brain/ingestion/models.py +80 -0
  54. package/lattice_brain/ingestion/pipeline.py +486 -0
  55. package/lattice_brain/ingestion/quality.py +209 -0
  56. package/lattice_brain/ingestion/routing.py +295 -0
  57. package/lattice_brain/multimodal/__init__.py +164 -0
  58. package/lattice_brain/multimodal/audio.py +77 -0
  59. package/lattice_brain/multimodal/common.py +118 -0
  60. package/lattice_brain/{multimodal.py → multimodal/images.py} +22 -262
  61. package/lattice_brain/multimodal/ports.py +169 -0
  62. package/lattice_brain/multimodal/video.py +410 -0
  63. package/lattice_brain/portability/__init__.py +90 -0
  64. package/lattice_brain/portability/_contract.py +42 -0
  65. package/lattice_brain/portability/backups.py +338 -0
  66. package/lattice_brain/portability/bundles.py +136 -0
  67. package/lattice_brain/portability/constants.py +93 -0
  68. package/lattice_brain/portability/fsops.py +138 -0
  69. package/lattice_brain/portability/service.py +41 -0
  70. package/lattice_brain/portability/sharing.py +714 -0
  71. package/lattice_brain/runtime/__init__.py +1 -1
  72. package/lattice_brain/runtime/multi_agent.py +1 -1
  73. package/lattice_brain/sealed_box.py +244 -0
  74. package/lattice_brain/synthesis.py +24 -1
  75. package/latticeai/__init__.py +1 -1
  76. package/latticeai/api/brain_intelligence.py +4 -0
  77. package/latticeai/api/chat.py +11 -0
  78. package/latticeai/api/chat_helpers.py +16 -3
  79. package/latticeai/api/chat_hybrid.py +32 -1
  80. package/latticeai/api/chronicle.py +63 -0
  81. package/latticeai/api/features.py +70 -0
  82. package/latticeai/api/local_files.py +102 -0
  83. package/latticeai/api/portability.py +39 -4
  84. package/latticeai/api/review_queue.py +126 -0
  85. package/latticeai/api/search.py +16 -2
  86. package/latticeai/core/agent/__init__.py +93 -0
  87. package/latticeai/core/agent/_contract.py +79 -0
  88. package/latticeai/core/agent/context.py +57 -0
  89. package/latticeai/core/agent/deps.py +125 -0
  90. package/latticeai/core/agent/execution.py +622 -0
  91. package/latticeai/core/agent/planning.py +145 -0
  92. package/latticeai/core/agent/recovery.py +157 -0
  93. package/latticeai/core/agent/runtime.py +210 -0
  94. package/latticeai/core/agent/verification.py +231 -0
  95. package/latticeai/core/config.py +4 -1
  96. package/latticeai/core/context_builder.py +6 -3
  97. package/latticeai/core/embedding_providers/__init__.py +151 -0
  98. package/latticeai/core/embedding_providers/base.py +199 -0
  99. package/latticeai/core/embedding_providers/captions.py +162 -0
  100. package/latticeai/core/embedding_providers/profiles.py +126 -0
  101. package/latticeai/core/embedding_providers/text.py +350 -0
  102. package/latticeai/core/embedding_providers/vision.py +352 -0
  103. package/latticeai/core/file_generation/__init__.py +115 -0
  104. package/latticeai/core/file_generation/bundles.py +76 -0
  105. package/latticeai/core/file_generation/extraction.py +154 -0
  106. package/latticeai/core/file_generation/inference.py +235 -0
  107. package/latticeai/core/file_generation/orchestration.py +152 -0
  108. package/latticeai/core/file_generation/prompting.py +117 -0
  109. package/latticeai/core/file_generation/repair.py +114 -0
  110. package/latticeai/core/file_generation/sanitize.py +61 -0
  111. package/latticeai/core/file_generation/validation.py +201 -0
  112. package/latticeai/core/legacy_compatibility.py +1 -1
  113. package/latticeai/core/marketplace.py +1 -1
  114. package/latticeai/core/messages.py +152 -0
  115. package/latticeai/core/model_compat.py +73 -2
  116. package/latticeai/core/workspace_os_constants.py +1 -1
  117. package/latticeai/integrations/telegram_bot/__init__.py +123 -0
  118. package/latticeai/integrations/telegram_bot/__main__.py +17 -0
  119. package/latticeai/integrations/telegram_bot/config.py +86 -0
  120. package/latticeai/integrations/telegram_bot/dispatch.py +311 -0
  121. package/latticeai/integrations/telegram_bot/flows.py +478 -0
  122. package/latticeai/integrations/telegram_bot/helpers.py +322 -0
  123. package/latticeai/integrations/telegram_bot/screens.py +394 -0
  124. package/latticeai/models/model_providers.py +12 -4
  125. package/latticeai/models/router/__init__.py +88 -0
  126. package/latticeai/models/router/_contract.py +66 -0
  127. package/latticeai/models/router/branding.py +56 -0
  128. package/latticeai/models/router/catalog.py +69 -0
  129. package/latticeai/models/router/documents.py +199 -0
  130. package/latticeai/models/router/errors.py +37 -0
  131. package/latticeai/models/router/generation.py +258 -0
  132. package/latticeai/models/router/loading.py +291 -0
  133. package/latticeai/models/router/local_models.py +85 -0
  134. package/latticeai/models/router/registry.py +147 -0
  135. package/latticeai/runtime/build_phases/__init__.py +82 -0
  136. package/latticeai/runtime/build_phases/features.py +407 -0
  137. package/latticeai/runtime/build_phases/foundation.py +555 -0
  138. package/latticeai/runtime/build_phases/web.py +492 -0
  139. package/latticeai/runtime/chat_wiring.py +4 -0
  140. package/latticeai/runtime/feature_toggle_wiring.py +163 -0
  141. package/latticeai/runtime/router_registration.py +11 -0
  142. package/latticeai/runtime/runtime_context.py +1 -0
  143. package/latticeai/services/app_context.py +8 -0
  144. package/latticeai/services/architecture_readiness.py +48 -19
  145. package/latticeai/services/automation_intelligence.py +22 -2
  146. package/latticeai/services/brain_intelligence/__init__.py +58 -0
  147. package/latticeai/services/brain_intelligence/_contract.py +71 -0
  148. package/latticeai/services/brain_intelligence/consistency.py +193 -0
  149. package/latticeai/services/brain_intelligence/constants.py +47 -0
  150. package/latticeai/services/brain_intelligence/digest.py +258 -0
  151. package/latticeai/services/brain_intelligence/health.py +331 -0
  152. package/latticeai/services/brain_intelligence/proposals.py +264 -0
  153. package/latticeai/services/brain_intelligence/sampling.py +84 -0
  154. package/latticeai/services/brain_intelligence/service.py +48 -0
  155. package/latticeai/services/chronicle.py +557 -0
  156. package/latticeai/services/command_center.py +10 -4
  157. package/latticeai/services/feature_toggles.py +502 -0
  158. package/latticeai/services/folder_watch.py +122 -1
  159. package/latticeai/services/hybrid_chat.py +56 -5
  160. package/latticeai/services/interop_bridges.py +978 -0
  161. package/latticeai/services/memory_service/__init__.py +52 -0
  162. package/latticeai/services/memory_service/_contract.py +100 -0
  163. package/latticeai/services/memory_service/brief.py +431 -0
  164. package/latticeai/services/memory_service/constants.py +57 -0
  165. package/latticeai/services/memory_service/maintenance.py +138 -0
  166. package/latticeai/services/memory_service/manager.py +186 -0
  167. package/latticeai/services/memory_service/proof.py +136 -0
  168. package/latticeai/services/memory_service/recall.py +225 -0
  169. package/latticeai/services/memory_service/service.py +48 -0
  170. package/latticeai/services/memory_service/stores.py +110 -0
  171. package/latticeai/services/model_capability_registry.py +434 -261
  172. package/latticeai/services/model_catalog.py +95 -61
  173. package/latticeai/services/model_recommendation.py +18 -11
  174. package/latticeai/services/model_runtime/__init__.py +322 -0
  175. package/latticeai/services/model_runtime/cloud.py +87 -0
  176. package/latticeai/services/model_runtime/download.py +282 -0
  177. package/latticeai/services/model_runtime/engines.py +341 -0
  178. package/latticeai/services/model_runtime/loading.py +178 -0
  179. package/latticeai/services/model_runtime/service.py +129 -0
  180. package/latticeai/services/model_runtime/state.py +131 -0
  181. package/latticeai/services/model_runtime/status.py +255 -0
  182. package/latticeai/services/multimodal_ports.py +26 -1
  183. package/latticeai/services/obsidian_bridge.py +16 -25
  184. package/latticeai/services/product_readiness.py +15 -7
  185. package/latticeai/services/search_service.py +149 -2
  186. package/latticeai/services/tool_dispatch.py +4 -0
  187. package/latticeai/setup/auto_setup.py +27 -30
  188. package/latticeai/setup/wizard/__init__.py +126 -0
  189. package/latticeai/setup/wizard/catalog.py +172 -0
  190. package/latticeai/setup/wizard/detect.py +323 -0
  191. package/latticeai/setup/wizard/install.py +348 -0
  192. package/latticeai/setup/wizard/paths.py +168 -0
  193. package/latticeai/setup/wizard/plans.py +74 -0
  194. package/latticeai/setup/wizard/recommend.py +320 -0
  195. package/package.json +6 -2
  196. package/scripts/bump_version.py +14 -0
  197. package/scripts/capture_release_evidence.mjs +33 -21
  198. package/scripts/check_current_release_docs.mjs +1 -1
  199. package/scripts/check_i18n_namespace_coverage.mjs +41 -4
  200. package/scripts/check_max_file_lines.mjs +102 -0
  201. package/scripts/check_release_evidence_bound.mjs +30 -15
  202. package/scripts/check_screenshot_pixel_delta.py +34 -4
  203. package/scripts/check_server_i18n.mjs +2 -0
  204. package/scripts/generate_rust_parity_fixtures.py +562 -0
  205. package/scripts/lib/mock_server_fingerprint.mjs +94 -0
  206. package/scripts/release_screen_claims.json +44 -2
  207. package/scripts/verify_hf_model_registry.py +253 -218
  208. package/src-tauri/Cargo.lock +361 -3
  209. package/src-tauri/Cargo.toml +6 -1
  210. package/src-tauri/src/backend.rs +349 -0
  211. package/src-tauri/src/folder.rs +33 -0
  212. package/src-tauri/src/main.rs +97 -399
  213. package/src-tauri/tauri.conf.json +1 -1
  214. package/static/app/asset-manifest.json +41 -37
  215. package/static/app/assets/Act-yYpYnn0v.js +1 -0
  216. package/static/app/assets/AdminConsole-DL3Cr5pL.js +1 -0
  217. package/static/app/assets/{Brain-CzCsI1mi.js → Brain-C1HBN0Wf.js} +2 -2
  218. package/static/app/assets/BrainHome-DoXRhUUC.js +2 -0
  219. package/static/app/assets/BrainSignals-6yR6ir5t.js +1 -0
  220. package/static/app/assets/Capture-CFIRsFNE.js +1 -0
  221. package/static/app/assets/Chronicle-BZbEgiwN.js +1 -0
  222. package/static/app/assets/CommandPalette-D2pMxC2I.js +1 -0
  223. package/static/app/assets/Library-DwO3yZST.js +1 -0
  224. package/static/app/assets/{LivingBrain-BXMWIK_2.js → LivingBrain-Jn1GK0-S.js} +1 -1
  225. package/static/app/assets/ProductFlow-B-w1R4Oo.js +1 -0
  226. package/static/app/assets/ReviewCard-6B27X8Vg.js +3 -0
  227. package/static/app/assets/System-DW8F-2xL.js +1 -0
  228. package/static/app/assets/arrow-left-DXvKg9U6.js +1 -0
  229. package/static/app/assets/{bot-4BvN07ux.js → bot-IM_E_Y12.js} +1 -1
  230. package/static/app/assets/brain-Ci1CkWjM.js +1 -0
  231. package/static/app/assets/{button-CDjtnAoU.js → button-COwyqfHM.js} +1 -1
  232. package/static/app/assets/circle-check-DfInj-qD.js +1 -0
  233. package/static/app/assets/{circle-pause-D_RMn7tp.js → circle-pause-DEM4A1Y5.js} +1 -1
  234. package/static/app/assets/{circle-play-B5OpB8ae.js → circle-play-C9djDuLd.js} +1 -1
  235. package/static/app/assets/{cpu-BIlWInHf.js → cpu-DFdo1gw-.js} +1 -1
  236. package/static/app/assets/{download-BtjXfL3z.js → download-SnJL6oqk.js} +1 -1
  237. package/static/app/assets/{folder-open-DefMpxI2.js → folder-open-CqZeDkjE.js} +1 -1
  238. package/static/app/assets/{hard-drive-BQ8NZVkw.js → hard-drive-j1jJXYYf.js} +1 -1
  239. package/static/app/assets/{index-vtEfYvQY.css → index-BLPb5lmE.css} +1 -1
  240. package/static/app/assets/index-_u5iUHDr.js +10 -0
  241. package/static/app/assets/input-B0lPdRQZ.js +1 -0
  242. package/static/app/assets/link-2-CoFbooHS.js +1 -0
  243. package/static/app/assets/{permissionCopy-BqZ5tsgL.js → permissionCopy-BsyLxtao.js} +1 -1
  244. package/static/app/assets/primitives-DEbN-d6p.js +1 -0
  245. package/static/app/assets/search-BybIWPNd.js +1 -0
  246. package/static/app/assets/{share-2-D5zg_0fY.js → share-2-CVtZ_ewX.js} +1 -1
  247. package/static/app/assets/{shield-alert-B5pZzkUb.js → shield-alert-CBi2GNWM.js} +1 -1
  248. package/static/app/assets/{textarea-nEVIweKY.js → textarea-DNMpB5ih.js} +1 -1
  249. package/static/app/assets/{useFocusTrap-Cm99AHlz.js → useFocusTrap-C83t3GXF.js} +1 -1
  250. package/static/app/assets/useMutation-DtbJDoyz.js +1 -0
  251. package/static/app/assets/{useQuery-Dm__N6bL.js → useQuery-Dcp1OChy.js} +1 -1
  252. package/static/app/assets/utils-BlZr7Pd4.js +4 -0
  253. package/static/app/assets/workspace-jJY4RuAV.js +1 -0
  254. package/static/app/index.html +4 -4
  255. package/static/sw.js +1 -1
  256. package/lattice_brain/graph/_kg_common.py +0 -1331
  257. package/lattice_brain/graph/discovery_index.py +0 -1141
  258. package/lattice_brain/graph/retrieval.py +0 -1120
  259. package/lattice_brain/graph/retrieval_vector.py +0 -1293
  260. package/lattice_brain/ingestion.py +0 -1377
  261. package/lattice_brain/portability.py +0 -1210
  262. package/latticeai/core/agent.py +0 -1412
  263. package/latticeai/core/embedding_providers.py +0 -1196
  264. package/latticeai/core/file_generation.py +0 -1047
  265. package/latticeai/integrations/telegram_bot.py +0 -1390
  266. package/latticeai/models/router.py +0 -1007
  267. package/latticeai/runtime/build_phases.py +0 -1422
  268. package/latticeai/services/brain_intelligence.py +0 -967
  269. package/latticeai/services/memory_service.py +0 -1177
  270. package/latticeai/services/model_runtime.py +0 -1281
  271. package/latticeai/setup/wizard.py +0 -1277
  272. package/static/app/assets/Act-D0HWqtn0.js +0 -1
  273. package/static/app/assets/AdminConsole-D-QDW-A4.js +0 -1
  274. package/static/app/assets/BrainHome-Btns-_TA.js +0 -2
  275. package/static/app/assets/BrainSignals-2dHQNkns.js +0 -1
  276. package/static/app/assets/Capture-CT8v1StE.js +0 -1
  277. package/static/app/assets/CommandPalette-DoLXC2KH.js +0 -1
  278. package/static/app/assets/Library-DDoxFE5c.js +0 -1
  279. package/static/app/assets/ProductFlow-DOYf7JIs.js +0 -1
  280. package/static/app/assets/ReviewCard-COQsqidK.js +0 -3
  281. package/static/app/assets/System-BRllvYXd.js +0 -1
  282. package/static/app/assets/arrow-left-DnyMzss-.js +0 -1
  283. package/static/app/assets/brain-uMb_5hnO.js +0 -1
  284. package/static/app/assets/index-0AvoEBzJ.js +0 -10
  285. package/static/app/assets/input-B_5ZJ9oy.js +0 -1
  286. package/static/app/assets/primitives-CVwew78r.js +0 -1
  287. package/static/app/assets/search-DkhnOKZt.js +0 -1
  288. package/static/app/assets/utils-DcDMoZIe.js +0 -4
  289. package/static/app/assets/workspace-LtRRSKTf.js +0 -1
@@ -1,1210 +0,0 @@
1
- """Knowledge Graph portability — local export / import / backup / restore.
2
-
3
- The Knowledge Graph is the user's durable asset, so it must be portable without
4
- any cloud service. Two complementary mechanisms, both fully local:
5
-
6
- * **Logical export/import** (JSON): nodes/edges/chunks/sources/provenance with a
7
- versioned header (schema + projection + embed-dim). Vectors are not in the
8
- artifact; the importer re-embeds with its own embedder and reports the
9
- resulting index state under ``result["index"]`` (``degraded: true`` means the
10
- content landed but recall is lexical-only until a rebuild succeeds). That is
11
- what makes it portable across machines.
12
- * **Binary backup/restore** (ZIP): a faithful snapshot of the SQLite DB (incl.
13
- vector embeddings) plus the blob directory, integrity-checked, for
14
- same-machine recovery.
15
- """
16
-
17
- from __future__ import annotations
18
-
19
- import base64
20
- import hashlib
21
- import json
22
- import logging
23
- import os
24
- import shutil
25
- import sqlite3
26
- import tempfile
27
- import zipfile
28
- from contextlib import closing
29
- from pathlib import Path, PurePosixPath
30
- from typing import Any, Dict, Iterable, List, Optional, Set
31
-
32
- from cryptography.exceptions import InvalidTag
33
- from cryptography.hazmat.primitives.ciphers.aead import AESGCM
34
-
35
- from .archive import (
36
- KDF_ITERATIONS,
37
- BrainArchivePaths,
38
- EncryptedBrainArchive,
39
- _derive_key,
40
- )
41
- from .quiet import quiet
42
- from .storage import (
43
- DockerPostgresWizard,
44
- PostgresEngine,
45
- SQLiteToPostgresMigrator,
46
- )
47
- from .utils import sha256_file as _sha256_file
48
- from .utils import utc_now_iso
49
-
50
- FORMAT = "latticeai.kg.export"
51
- FORMAT_VERSION = 1
52
- BACKUP_FORMAT = "latticeai.kg.backup"
53
-
54
- # ── selective subgraph share (v11.1.0 prototype, off by default) ─────────────
55
- #: Logical format of a *partial* bundle: a chosen slice of the graph plus its
56
- #: provenance, signed by this device. Deliberately distinct from ``FORMAT`` so
57
- #: the whole-graph importer can never be handed a subgraph by accident.
58
- SUBGRAPH_FORMAT = "latticeai.kg.subgraph"
59
- SUBGRAPH_FORMAT_VERSION = 1
60
- #: Encrypted on-disk envelope for the same bundle, reusing the ``.latticebrain``
61
- #: passphrase mechanism (PBKDF2-SHA256 + AES-256-GCM) from ``archive.py``.
62
- SUBGRAPH_ARCHIVE_FORMAT = "latticebrain.subgraph"
63
- #: Opt-in gate. Off unless the operator sets it: sharing knowledge with another
64
- #: Brain is a network act, and local-first means those are never on by default.
65
- BRAIN_NETWORK_ENV = "LATTICEAI_BRAIN_NETWORK"
66
- _TRUTHY = frozenset({"1", "true", "yes", "on"})
67
- #: A received bundle becomes review items, never a merge. The cap keeps one
68
- #: peer from flooding the inbox; what it dropped is reported, not hidden.
69
- SUBGRAPH_PROPOSAL_CAP = 200
70
- #: Review-queue vocabulary. ``kg_change_digest`` is the existing source for
71
- #: "the graph would change"; the kind distinguishes shared-subgraph items.
72
- SUBGRAPH_REVIEW_SOURCE = "kg_change_digest"
73
- SUBGRAPH_REVIEW_KIND = "shared_subgraph_node"
74
- #: Mirrors ``services.review_queue.OPEN_STATUSES``. Duplicated as a literal
75
- #: rather than imported because ``lattice_brain`` must not depend on the
76
- #: ``latticeai`` service layer; the review sink is injected, not imported.
77
- _OPEN_REVIEW_STATUSES = frozenset({"pending", "snoozed"})
78
-
79
- BRAIN_NETWORK_DISABLED_DETAIL = (
80
- "Brain Network sharing is off. It is opt-in by design: set "
81
- f"{BRAIN_NETWORK_ENV}=1 to enable selective subgraph export and receipt."
82
- )
83
-
84
-
85
- class BrainNetworkDisabled(PermissionError):
86
- """Raised when a share surface is used while the opt-in flag is off."""
87
-
88
- def __init__(self, detail: str = BRAIN_NETWORK_DISABLED_DETAIL) -> None:
89
- super().__init__(detail)
90
-
91
-
92
- def brain_network_enabled() -> bool:
93
- """True only when the operator explicitly opted in (default: off)."""
94
- return os.getenv(BRAIN_NETWORK_ENV, "").strip().lower() in _TRUTHY
95
-
96
-
97
- def require_brain_network() -> None:
98
- if not brain_network_enabled():
99
- raise BrainNetworkDisabled()
100
-
101
-
102
- def _stamp() -> str:
103
- return utc_now_iso().replace(":", "").replace("-", "").replace(".", "")[:15]
104
-
105
-
106
- def _safe_zip_names(names) -> None:
107
- for name in names:
108
- path = PurePosixPath(name)
109
- if path.is_absolute() or ".." in path.parts:
110
- raise ValueError(f"Backup archive contains unsafe path: {name}")
111
-
112
-
113
- def _pre_restore_backup_dir(anchor: Path) -> Path:
114
- backup_dir = anchor.parent / f"{anchor.name}.pre-restore-{_stamp()}"
115
- index = 1
116
- while backup_dir.exists():
117
- backup_dir = anchor.parent / f"{anchor.name}.pre-restore-{_stamp()}-{index}"
118
- index += 1
119
- backup_dir.mkdir(parents=True)
120
- return backup_dir
121
-
122
-
123
- def _sqlite_siblings(db_path: Path) -> tuple[Path, Path, Path]:
124
- return (db_path, Path(str(db_path) + "-wal"), Path(str(db_path) + "-shm"))
125
-
126
-
127
- def _checkpoint_sqlite(db_path: Path) -> None:
128
- if not db_path.exists():
129
- return
130
- try:
131
- with closing(sqlite3.connect(str(db_path))) as conn, conn:
132
- conn.execute("PRAGMA wal_checkpoint(FULL)")
133
- except sqlite3.Error:
134
- # Best-effort only. Existing sibling backup/restore still preserves
135
- # the WAL files if a live connection prevents a checkpoint.
136
- return
137
-
138
-
139
- def _restore_sibling(path: Path, backup: Path) -> None:
140
- try:
141
- shutil.copy2(backup, path)
142
- except FileNotFoundError:
143
- # The backup vanished (or never existed — transient -wal/-shm): the
144
- # honest reconstruction is "no such sibling", never a crash that
145
- # masks the error that triggered the rollback.
146
- path.unlink(missing_ok=True)
147
-
148
-
149
- def _replace_sqlite_atomically(src: Path, dest: Path, backup_dir: Path) -> None:
150
- dest.parent.mkdir(parents=True, exist_ok=True)
151
- tmp = dest.parent / f".{dest.name}.restore-{_stamp()}-{os.getpid()}.tmp"
152
- shutil.copyfile(src, tmp)
153
- backups: dict[Path, Path] = {}
154
- try:
155
- _checkpoint_sqlite(dest)
156
- # -wal/-shm are transient: another live connection can checkpoint and
157
- # remove them between exists() and the copy/unlink. Treat a vanished
158
- # sibling as "nothing to preserve" instead of crashing the restore.
159
- for sibling in _sqlite_siblings(dest):
160
- backup = backup_dir / sibling.name
161
- try:
162
- shutil.copy2(sibling, backup)
163
- except FileNotFoundError:
164
- quiet()
165
- continue
166
- backups[sibling] = backup
167
- for sibling in _sqlite_siblings(dest)[1:]:
168
- sibling.unlink(missing_ok=True)
169
- os.replace(tmp, dest)
170
- except Exception:
171
- # Recovery I/O must never replace the swap error being reported —
172
- # an exception raised here would mask it (the CI-observed
173
- # "[Errno 2]" over the real failure). Best-effort restore, then
174
- # always re-raise the original.
175
- try:
176
- tmp.unlink(missing_ok=True)
177
- except OSError as cleanup_exc:
178
- logging.warning("restore tmp cleanup failed: %s", cleanup_exc)
179
- for sibling in _sqlite_siblings(dest):
180
- try:
181
- _restore_sibling(sibling, backups.get(sibling, backup_dir / sibling.name))
182
- except OSError as rollback_exc:
183
- logging.warning(
184
- "restore sibling rollback incomplete for %s: %s",
185
- sibling, rollback_exc,
186
- )
187
- raise
188
-
189
-
190
- def _rollback_sqlite_from_backup(dest: Path, backup_dir: Path) -> None:
191
- for sibling in _sqlite_siblings(dest):
192
- _restore_sibling(sibling, backup_dir / sibling.name)
193
-
194
-
195
- def _replace_tree_with_backup(src: Optional[Path], dest: Path, backup_dir: Path) -> None:
196
- dest.parent.mkdir(parents=True, exist_ok=True)
197
- staged = dest.parent / f".{dest.name}.restore-{_stamp()}-{os.getpid()}"
198
- backup = backup_dir / dest.name
199
- if src and src.exists():
200
- shutil.copytree(src, staged)
201
- else:
202
- staged.mkdir(parents=True)
203
- try:
204
- if dest.exists():
205
- shutil.copytree(dest, backup)
206
- shutil.rmtree(dest)
207
- os.replace(staged, dest)
208
- except Exception:
209
- # Same masking guard as the sqlite swap: rollback I/O is
210
- # best-effort and the original failure always propagates.
211
- try:
212
- if staged.exists():
213
- shutil.rmtree(staged)
214
- if dest.exists():
215
- shutil.rmtree(dest)
216
- if backup.exists():
217
- shutil.copytree(backup, dest)
218
- except OSError as rollback_exc:
219
- logging.warning("blob tree rollback incomplete: %s", rollback_exc)
220
- raise
221
-
222
-
223
- #: Sender-identifying fields stripped from a shared bundle by default. The
224
- #: knowledge travels; "which account, on which machine, at which path" does not.
225
- _REDACTED_FIELDS = frozenset({"owner", "user_email", "source_uri", "permissions"})
226
- #: The signed payload's key set. One tuple so export and verify can never
227
- #: disagree about what the digest covers.
228
- _PAYLOAD_KEYS = (
229
- "nodes", "chunk_nodes", "edges", "chunks", "knowledge_sources", "provenance",
230
- )
231
- #: Node types a one-hop expansion never admits — they describe the sender
232
- #: (identity, local paths), not the knowledge being shared.
233
- _NEIGHBOR_EXCLUDED_TYPES = frozenset({"Person", "Source"})
234
-
235
-
236
- def _canonical_digest(payload: Dict[str, Any]) -> str:
237
- """sha256 over the canonical JSON of a bundle payload.
238
-
239
- Pinned in the signed header so the Ed25519 signature covers the contents,
240
- not just the manifest describing them.
241
- """
242
- canonical = json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
243
- return hashlib.sha256(canonical).hexdigest()
244
-
245
-
246
- def _node_source_type(node: Dict[str, Any]) -> str:
247
- try:
248
- metadata = json.loads(node.get("metadata_json") or "{}")
249
- except (TypeError, ValueError):
250
- return ""
251
- if not isinstance(metadata, dict):
252
- return ""
253
- return str(metadata.get("source_type") or "").strip().lower()
254
-
255
-
256
- def _select_node_ids(nodes: List[Dict[str, Any]], selection: Dict[str, Any]) -> Set[str]:
257
- wanted_ids = set(selection["node_ids"])
258
- wanted_types = set(selection["node_types"])
259
- wanted_sources = set(selection["source_types"])
260
- keep: Set[str] = set()
261
- for node in nodes:
262
- node_id = str(node.get("id"))
263
- if node_id in wanted_ids:
264
- keep.add(node_id)
265
- continue
266
- if wanted_types and str(node.get("type") or "").strip().lower() in wanted_types:
267
- keep.add(node_id)
268
- continue
269
- if wanted_sources and _node_source_type(node) in wanted_sources:
270
- keep.add(node_id)
271
- return keep
272
-
273
-
274
- def _expand_neighbors(
275
- keep: Set[str], edges: List[Dict[str, Any]], known: Dict[str, str]
276
- ) -> Set[str]:
277
- """One hop out from the selection, in both directions, within the scope.
278
-
279
- ``Person`` and ``Source`` neighbours are never pulled in implicitly: a
280
- Person node's label *is* an email address and a Source node's summary *is*
281
- a local file path, so "share this decision and what it touches" would
282
- quietly ship the sender's identity and directory layout. Naming one of
283
- those node ids explicitly still exports it — that is a decision, not a
284
- side effect.
285
- """
286
- expanded = set(keep)
287
-
288
- def _admit(candidate: str) -> None:
289
- if candidate in known and known[candidate] not in _NEIGHBOR_EXCLUDED_TYPES:
290
- expanded.add(candidate)
291
-
292
- for edge in edges:
293
- source = str(edge.get("from_node"))
294
- target = str(edge.get("to_node"))
295
- if source in keep:
296
- _admit(target)
297
- if target in keep:
298
- _admit(source)
299
- return expanded
300
-
301
-
302
- def _strip_fields(raw_json: Optional[str]) -> Optional[str]:
303
- if not raw_json:
304
- return raw_json
305
- try:
306
- parsed = json.loads(raw_json)
307
- except (TypeError, ValueError):
308
- return raw_json
309
- if not isinstance(parsed, dict):
310
- return raw_json
311
- cleaned = {k: v for k, v in parsed.items() if k not in _REDACTED_FIELDS}
312
- return json.dumps(cleaned, ensure_ascii=False, sort_keys=True)
313
-
314
-
315
- def _redact_node(node: Dict[str, Any]) -> Dict[str, Any]:
316
- redacted = dict(node)
317
- redacted["metadata_json"] = _strip_fields(node.get("metadata_json"))
318
- redacted["raw_json"] = _strip_fields(node.get("raw_json"))
319
- return redacted
320
-
321
-
322
- def _redact_provenance_row(row: Dict[str, Any]) -> Dict[str, Any]:
323
- redacted = dict(row)
324
- redacted["owner"] = None
325
- redacted["source_uri"] = None
326
- redacted["permissions_json"] = "{}"
327
- redacted["metadata_json"] = _strip_fields(row.get("metadata_json"))
328
- return redacted
329
-
330
-
331
- def _scope_node(node: Dict[str, Any], workspace_id: str) -> Dict[str, Any]:
332
- """Stamp the accepting workspace onto an incoming node.
333
-
334
- Received knowledge belongs to the workspace that accepted it. Without this
335
- the projection would file it as legacy-global — machine-shared — which is a
336
- wider scope than the reviewer chose.
337
- """
338
- scoped = dict(node)
339
- try:
340
- metadata = json.loads(scoped.get("metadata_json") or "{}")
341
- except (TypeError, ValueError):
342
- metadata = {}
343
- if not isinstance(metadata, dict):
344
- metadata = {}
345
- metadata["workspace_id"] = workspace_id
346
- scoped["metadata_json"] = json.dumps(metadata, ensure_ascii=False, sort_keys=True)
347
- return scoped
348
-
349
-
350
- def _shared_node_summary(node: Dict[str, Any], verdict: Dict[str, Any]) -> str:
351
- body = str(node.get("summary") or "").strip()
352
- origin = verdict.get("origin") or "unknown device"
353
- prefix = f"[{origin}] "
354
- return (prefix + body)[:500] if body else prefix.strip()
355
-
356
-
357
- class KGPortabilityService:
358
- def __init__(self, *, knowledge_graph: Any, data_dir, enable_graph: bool = True, device_identity: Any = None) -> None:
359
- self._kg = knowledge_graph
360
- self._data_dir = Path(data_dir)
361
- self._enable = bool(enable_graph)
362
- self._exports_dir = self._data_dir / "workspace_exports"
363
- # v4 sovereignty: when a DeviceIdentity is wired, exports are signed
364
- # and imports record origin provenance. Pre-v4 unsigned bundles stay
365
- # importable locally (origin='unsigned-legacy') — signatures are
366
- # mandatory only on the Brain Network peer path.
367
- self._identity = device_identity
368
-
369
- def available(self) -> bool:
370
- return self._enable and self._kg is not None
371
-
372
- def _require(self) -> None:
373
- if not self.available():
374
- raise RuntimeError("Knowledge Graph is disabled (LATTICEAI_ENABLE_GRAPH).")
375
-
376
- # ── logical export / import ──────────────────────────────────────────────
377
- def export(
378
- self,
379
- *,
380
- workspace_id: Optional[str] = None,
381
- include_legacy_global: bool = False,
382
- ) -> Dict[str, Any]:
383
- self._require()
384
- data = self._kg.export_graph_data(
385
- workspace_id=workspace_id,
386
- include_legacy_global=include_legacy_global,
387
- )
388
- header = {
389
- "format": FORMAT,
390
- "format_version": FORMAT_VERSION,
391
- **self._kg.schema_versions(),
392
- "exported_at": utc_now_iso(),
393
- "workspace_id": workspace_id,
394
- "include_legacy_global": bool(include_legacy_global),
395
- "counts": data.get("counts"),
396
- }
397
- artifact = {"header": header, **data}
398
- if self._identity is not None:
399
- artifact["signature"] = self._identity.sign_manifest(header)
400
- return artifact
401
-
402
- def export_to_file(
403
- self,
404
- path=None,
405
- *,
406
- workspace_id: Optional[str] = None,
407
- include_legacy_global: bool = False,
408
- ) -> Dict[str, Any]:
409
- artifact = self.export(
410
- workspace_id=workspace_id,
411
- include_legacy_global=include_legacy_global,
412
- )
413
- self._exports_dir.mkdir(parents=True, exist_ok=True)
414
- path = Path(path) if path else self._exports_dir / f"kg-export-{_stamp()}.json"
415
- path.write_text(json.dumps(artifact, ensure_ascii=False, indent=2), encoding="utf-8")
416
- return {"path": str(path), "header": artifact["header"], "bytes": path.stat().st_size}
417
-
418
- def import_data(self, artifact: Dict[str, Any], *, mode: str = "merge", dry_run: bool = False) -> Dict[str, Any]:
419
- self._require()
420
- if not isinstance(artifact, dict) or "nodes" not in artifact:
421
- raise ValueError("Invalid Knowledge Graph export artifact.")
422
- if mode not in ("merge", "replace"):
423
- raise ValueError("mode must be 'merge' or 'replace'.")
424
- origin = "unsigned-legacy"
425
- signature = artifact.get("signature")
426
- if signature:
427
- from .graph.identity import verify_manifest
428
-
429
- if not verify_manifest(artifact.get("header") or {}, signature):
430
- raise ValueError("Bundle signature verification failed — refusing to import.")
431
- origin = f"device:{signature.get('fingerprint') or 'unknown'}"
432
- result = self._kg.import_graph_data(artifact, mode=mode, dry_run=dry_run)
433
- result["header"] = artifact.get("header")
434
- result["origin"] = origin
435
- result["signed"] = bool(signature)
436
- if not dry_run:
437
- try:
438
- self._kg.record_provenance(
439
- node_id="import:" + str((artifact.get("header") or {}).get("exported_at") or utc_now_iso()),
440
- source_type="bundle_import",
441
- pipeline="kg-portability",
442
- owner=None,
443
- metadata={"origin": origin, "mode": mode,
444
- "counts": (artifact.get("header") or {}).get("counts")},
445
- )
446
- except Exception:
447
- quiet()
448
- return result
449
-
450
- def import_from_file(self, path, *, mode: str = "merge", dry_run: bool = False) -> Dict[str, Any]:
451
- artifact = json.loads(Path(path).read_text(encoding="utf-8"))
452
- return self.import_data(artifact, mode=mode, dry_run=dry_run)
453
-
454
- # ── selective subgraph share (opt-in prototype, off by default) ──────────
455
- def share_status(self) -> Dict[str, Any]:
456
- """Honest read of the share surface. Safe to call while it is off.
457
-
458
- A status route that refuses while disabled cannot tell a UI *why* the
459
- feature is missing, so this one always answers — with ``enabled:
460
- false`` and the flag that turns it on.
461
- """
462
- enabled = brain_network_enabled()
463
- return {
464
- "enabled": enabled,
465
- "flag": BRAIN_NETWORK_ENV,
466
- "format": SUBGRAPH_FORMAT,
467
- "format_version": SUBGRAPH_FORMAT_VERSION,
468
- "graph_available": self.available(),
469
- "signing": self._identity is not None,
470
- "device": self._share_device(),
471
- "proposal_cap": SUBGRAPH_PROPOSAL_CAP,
472
- # Stated rather than implied: the bundle is signed by this device
473
- # and encrypted with a shared passphrase. Encrypting *to a
474
- # recipient's public key* is not implemented in this release.
475
- "encryption": "passphrase",
476
- "recipient_public_key_encryption": False,
477
- "detail": None if enabled else BRAIN_NETWORK_DISABLED_DETAIL,
478
- }
479
-
480
- def _share_device(self) -> Dict[str, Any]:
481
- """Signer identity for a bundle — key material only, no storage hints."""
482
- if self._identity is None:
483
- return {}
484
- described = self._identity.describe()
485
- return {
486
- "fingerprint": described.get("fingerprint"),
487
- "public_key": described.get("public_key"),
488
- "algorithm": described.get("algorithm"),
489
- }
490
-
491
- def export_subgraph(
492
- self,
493
- *,
494
- node_ids: Optional[Iterable[str]] = None,
495
- node_types: Optional[Iterable[str]] = None,
496
- source_types: Optional[Iterable[str]] = None,
497
- workspace_id: Optional[str] = None,
498
- include_legacy_global: bool = False,
499
- include_neighbors: bool = False,
500
- redact_provenance: bool = True,
501
- ) -> Dict[str, Any]:
502
- """A chosen slice of the graph, with provenance, signed by this device.
503
-
504
- At least one selector is required. "Share everything" already exists as
505
- :meth:`export`; a *selective* door that silently defaults to the whole
506
- Brain is how an accident becomes a leak.
507
-
508
- ``include_neighbors`` widens the selection by one hop (both directions)
509
- so "share this decision" can carry the decision's immediate relations.
510
- ``redact_provenance`` (default on) strips the sender's local identity —
511
- owner, source URI, permissions — from both node metadata and the
512
- provenance rows, and records that it did so in the header.
513
-
514
- Knowledge-source rows (connected folder registrations, with their local
515
- paths) are never included; they describe the sender's machine, not the
516
- knowledge.
517
- """
518
- require_brain_network()
519
- self._require()
520
- if self._identity is None:
521
- raise RuntimeError(
522
- "A device identity is required to share a subgraph; "
523
- "an unsigned bundle cannot be attributed and would be refused."
524
- )
525
- wanted_ids = sorted({str(v) for v in (node_ids or []) if str(v).strip()})
526
- wanted_types = sorted({str(v).strip().lower() for v in (node_types or []) if str(v).strip()})
527
- wanted_sources = sorted({str(v).strip().lower() for v in (source_types or []) if str(v).strip()})
528
- selection: Dict[str, Any] = {
529
- "node_ids": wanted_ids,
530
- "node_types": wanted_types,
531
- "source_types": wanted_sources,
532
- "include_neighbors": bool(include_neighbors),
533
- "workspace_id": workspace_id,
534
- }
535
- if not (wanted_ids or wanted_types or wanted_sources):
536
- raise ValueError(
537
- "A subgraph share must name what to share "
538
- "(node_ids, node_types, or source_types)."
539
- )
540
- data = self._kg.export_graph_data(
541
- workspace_id=workspace_id, include_legacy_global=include_legacy_global,
542
- )
543
- all_nodes = data.get("nodes") or []
544
- # Chunks are derived retrieval units, not shareable subjects: they are
545
- # never selectable, but they travel with the node they belong to so the
546
- # receiver can actually search the shared content rather than a summary.
547
- nodes = [n for n in all_nodes if str(n.get("type")) != "Chunk"]
548
- chunk_nodes_by_id = {
549
- str(n.get("id")): n for n in all_nodes if str(n.get("type")) == "Chunk"
550
- }
551
- edges = list(data.get("edges") or [])
552
- keep = _select_node_ids(nodes, selection)
553
- present = {str(n.get("id")): str(n.get("type") or "") for n in nodes}
554
- missing = [nid for nid in wanted_ids if nid not in present]
555
- if include_neighbors:
556
- keep = _expand_neighbors(keep, edges, present)
557
- if not keep:
558
- raise ValueError("The selection matched no nodes; nothing was exported.")
559
- kept_nodes = [n for n in nodes if str(n.get("id")) in keep]
560
- kept_chunks = [c for c in data.get("chunks") or [] if str(c.get("source_node")) in keep]
561
- kept_chunk_nodes = [
562
- chunk_nodes_by_id[str(c.get("id"))]
563
- for c in kept_chunks
564
- if str(c.get("id")) in chunk_nodes_by_id
565
- ]
566
- carried = keep | {str(n.get("id")) for n in kept_chunk_nodes}
567
- kept_edges = [
568
- e for e in edges
569
- if str(e.get("from_node")) in carried and str(e.get("to_node")) in carried
570
- ]
571
- kept_prov = [p for p in data.get("provenance") or [] if str(p.get("node_id")) in keep]
572
- if redact_provenance:
573
- kept_nodes = [_redact_node(n) for n in kept_nodes]
574
- kept_chunk_nodes = [_redact_node(n) for n in kept_chunk_nodes]
575
- kept_prov = [_redact_provenance_row(p) for p in kept_prov]
576
- payload = {
577
- "nodes": kept_nodes,
578
- "chunk_nodes": kept_chunk_nodes,
579
- "edges": kept_edges,
580
- "chunks": kept_chunks,
581
- "knowledge_sources": [],
582
- "provenance": kept_prov,
583
- }
584
- counts = {key: len(value) for key, value in payload.items()}
585
- header = {
586
- "format": SUBGRAPH_FORMAT,
587
- "format_version": SUBGRAPH_FORMAT_VERSION,
588
- **self._kg.schema_versions(),
589
- "exported_at": utc_now_iso(),
590
- "workspace_id": workspace_id,
591
- "selection": selection,
592
- "counts": counts,
593
- "unmatched_node_ids": missing,
594
- "redacted": sorted(_REDACTED_FIELDS) if redact_provenance else [],
595
- "includes_knowledge_sources": False,
596
- "device": self._share_device(),
597
- # The signature covers the header; the header pins the payload.
598
- "payload_sha256": _canonical_digest(payload),
599
- }
600
- return {
601
- "header": header,
602
- **payload,
603
- "signature": self._identity.sign_manifest(header),
604
- }
605
-
606
- def export_subgraph_archive(
607
- self,
608
- dest_path=None,
609
- *,
610
- passphrase: str,
611
- **selection: Any,
612
- ) -> Dict[str, Any]:
613
- """Write the signed subgraph as an encrypted ``.latticebrain`` file.
614
-
615
- Same passphrase mechanism as the whole-Brain archive (PBKDF2-SHA256 →
616
- AES-256-GCM), a different ``format`` so the two can never be confused.
617
- The header and signature stay outside the ciphertext on purpose: a
618
- recipient can see *who* sent a bundle, and how big it is, before typing
619
- a passphrase into it.
620
- """
621
- artifact = self.export_subgraph(**selection)
622
- self._exports_dir.mkdir(parents=True, exist_ok=True)
623
- dest = Path(dest_path) if dest_path else self._exports_dir / f"subgraph-{_stamp()}.latticebrain"
624
- if dest.suffix != ".latticebrain":
625
- dest = dest.with_suffix(".latticebrain")
626
- body = {key: value for key, value in artifact.items() if key not in {"header", "signature"}}
627
- plaintext = json.dumps(body, ensure_ascii=False, sort_keys=True).encode("utf-8")
628
- salt = os.urandom(16)
629
- nonce = os.urandom(12)
630
- ciphertext = AESGCM(_derive_key(passphrase, salt)).encrypt(nonce, plaintext, None)
631
- envelope = {
632
- "format": SUBGRAPH_ARCHIVE_FORMAT,
633
- "format_version": SUBGRAPH_FORMAT_VERSION,
634
- "created_at": utc_now_iso(),
635
- "kdf": {
636
- "name": "PBKDF2HMAC-SHA256",
637
- "iterations": KDF_ITERATIONS,
638
- "salt": base64.b64encode(salt).decode("ascii"),
639
- },
640
- "cipher": {"name": "AES-256-GCM", "nonce": base64.b64encode(nonce).decode("ascii")},
641
- "header": artifact["header"],
642
- "signature": artifact["signature"],
643
- "payload": base64.b64encode(ciphertext).decode("ascii"),
644
- }
645
- dest.parent.mkdir(parents=True, exist_ok=True)
646
- dest.write_text(json.dumps(envelope, ensure_ascii=False, indent=2), encoding="utf-8")
647
- return {
648
- "path": str(dest),
649
- "bytes": dest.stat().st_size,
650
- "encrypted": True,
651
- "format": SUBGRAPH_ARCHIVE_FORMAT,
652
- "header": artifact["header"],
653
- "counts": artifact["header"]["counts"],
654
- }
655
-
656
- def read_subgraph_archive(self, path, *, passphrase: str) -> Dict[str, Any]:
657
- """Decrypt an encrypted subgraph bundle back into a signed artifact."""
658
- require_brain_network()
659
- source = Path(path)
660
- if not source.exists():
661
- raise FileNotFoundError(f"Subgraph bundle not found: {source}")
662
- try:
663
- envelope = json.loads(source.read_text(encoding="utf-8"))
664
- except json.JSONDecodeError as exc:
665
- raise ValueError("Subgraph bundle is not valid JSON.") from exc
666
- if envelope.get("format") != SUBGRAPH_ARCHIVE_FORMAT:
667
- raise ValueError("Not a .latticebrain subgraph bundle.")
668
- try:
669
- salt = base64.b64decode(envelope["kdf"]["salt"])
670
- nonce = base64.b64decode(envelope["cipher"]["nonce"])
671
- ciphertext = base64.b64decode(envelope["payload"])
672
- except (KeyError, TypeError, ValueError) as exc:
673
- raise ValueError("Subgraph bundle is missing encryption metadata.") from exc
674
- try:
675
- plaintext = AESGCM(_derive_key(passphrase, salt)).decrypt(nonce, ciphertext, None)
676
- except InvalidTag as exc:
677
- raise ValueError(
678
- "Subgraph decryption failed; the passphrase or the bundle is invalid."
679
- ) from exc
680
- body = json.loads(plaintext.decode("utf-8"))
681
- return {
682
- "header": envelope.get("header") or {},
683
- **body,
684
- "signature": envelope.get("signature") or {},
685
- }
686
-
687
- def verify_subgraph(self, artifact: Dict[str, Any]) -> Dict[str, Any]:
688
- """Signature + payload-digest verdict for a received bundle.
689
-
690
- Fail-closed and specific: the caller learns *which* check failed, and
691
- an unsigned bundle is a failure here (unlike a local file import, where
692
- pre-v4 unsigned artifacts stay importable).
693
- """
694
- errors: List[str] = []
695
- header = artifact.get("header") if isinstance(artifact, dict) else None
696
- header = header if isinstance(header, dict) else {}
697
- signature = artifact.get("signature") if isinstance(artifact, dict) else None
698
- signature = signature if isinstance(signature, dict) else {}
699
- if header.get("format") != SUBGRAPH_FORMAT:
700
- errors.append("not a Lattice subgraph bundle")
701
- incoming_version = int(header.get("format_version") or 0)
702
- if incoming_version > SUBGRAPH_FORMAT_VERSION:
703
- errors.append(
704
- f"bundle format version {incoming_version} is newer than this build "
705
- f"({SUBGRAPH_FORMAT_VERSION})"
706
- )
707
- if not signature.get("signature"):
708
- errors.append("bundle is unsigned")
709
- else:
710
- from .graph.identity import verify_manifest
711
-
712
- if not verify_manifest(header, signature):
713
- errors.append("signature does not match the bundle header")
714
- expected = header.get("payload_sha256")
715
- if expected:
716
- payload = {key: artifact.get(key) or [] for key in _PAYLOAD_KEYS}
717
- if _canonical_digest(payload) != expected:
718
- errors.append("bundle contents do not match the signed digest")
719
- else:
720
- errors.append("bundle header does not pin its payload digest")
721
- fingerprint = str(signature.get("fingerprint") or "") or None
722
- return {
723
- "ok": not errors,
724
- "errors": errors,
725
- "fingerprint": fingerprint,
726
- "origin": f"device:{fingerprint}" if fingerprint and not errors else None,
727
- "exported_at": header.get("exported_at"),
728
- "counts": header.get("counts") or {},
729
- }
730
-
731
- def import_subgraph_proposals(
732
- self,
733
- artifact: Dict[str, Any],
734
- *,
735
- review_sink: Any,
736
- workspace_id: Optional[str] = None,
737
- user_email: Optional[str] = None,
738
- dry_run: bool = False,
739
- cap: int = SUBGRAPH_PROPOSAL_CAP,
740
- ) -> Dict[str, Any]:
741
- """Receive a subgraph as *proposals*, never as a merge.
742
-
743
- Every node becomes one review item carrying its edges, chunks, and
744
- provenance, stamped with the sending device's fingerprint and the
745
- signature verdict. Nothing reaches the graph until a human accepts an
746
- item through :meth:`accept_subgraph_proposal`.
747
- """
748
- require_brain_network()
749
- self._require()
750
- verdict = self.verify_subgraph(artifact)
751
- if not verdict["ok"]:
752
- raise ValueError(
753
- "Refusing the bundle: " + "; ".join(verdict["errors"])
754
- )
755
- header = dict(artifact.get("header") or {})
756
- nodes = [n for n in artifact.get("nodes") or [] if n.get("id")]
757
- edges = list(artifact.get("edges") or [])
758
- chunks = list(artifact.get("chunks") or [])
759
- chunk_nodes = {str(n.get("id")): n for n in artifact.get("chunk_nodes") or []}
760
- provenance = list(artifact.get("provenance") or [])
761
- cap = max(1, int(cap))
762
- selected = nodes[:cap]
763
- summary: Dict[str, Any] = {
764
- "status": "dry_run" if dry_run else "proposed",
765
- "origin": verdict["origin"],
766
- "fingerprint": verdict["fingerprint"],
767
- "signed": True,
768
- "signature_verified": True,
769
- "nodes": len(nodes),
770
- "edges": len(edges),
771
- "chunks": len(chunks),
772
- "proposed": len(selected),
773
- "skipped": len(nodes) - len(selected),
774
- "capped": len(nodes) > len(selected),
775
- "exported_at": header.get("exported_at"),
776
- "items": [],
777
- }
778
- if dry_run:
779
- return summary
780
- for node in selected:
781
- node_id = str(node.get("id"))
782
- node_chunks = [c for c in chunks if str(c.get("source_node")) == node_id]
783
- carried = {node_id} | {str(c.get("id")) for c in node_chunks}
784
- item = review_sink.create(
785
- title=str(node.get("title") or node_id)[:200],
786
- summary=_shared_node_summary(node, verdict),
787
- source=SUBGRAPH_REVIEW_SOURCE,
788
- kind=SUBGRAPH_REVIEW_KIND,
789
- payload={
790
- "kind": SUBGRAPH_REVIEW_KIND,
791
- "node": node,
792
- "chunk_nodes": [
793
- chunk_nodes[str(c.get("id"))]
794
- for c in node_chunks
795
- if str(c.get("id")) in chunk_nodes
796
- ],
797
- "edges": [
798
- e for e in edges
799
- if str(e.get("from_node")) in carried or str(e.get("to_node")) in carried
800
- ],
801
- "chunks": node_chunks,
802
- "provenance": [p for p in provenance if str(p.get("node_id")) == node_id],
803
- "graph_schema_version": header.get("graph_schema_version"),
804
- },
805
- provenance={
806
- "origin": verdict["origin"],
807
- "fingerprint": verdict["fingerprint"],
808
- "public_key": (artifact.get("signature") or {}).get("public_key"),
809
- "signature_verified": True,
810
- "exported_at": header.get("exported_at"),
811
- "source_detail": "brain_network_subgraph",
812
- "redacted": header.get("redacted") or [],
813
- },
814
- user_email=user_email,
815
- workspace_id=workspace_id,
816
- )
817
- summary["items"].append(item.get("id"))
818
- try:
819
- self._kg.record_provenance(
820
- node_id="subgraph-proposal:" + str(header.get("exported_at") or utc_now_iso()),
821
- source_type="subgraph_share",
822
- pipeline="kg-portability",
823
- owner=user_email,
824
- workspace_id=workspace_id,
825
- metadata={
826
- "origin": verdict["origin"],
827
- "proposed": len(selected),
828
- "counts": header.get("counts"),
829
- },
830
- )
831
- except Exception: # noqa: BLE001 — the proposals already landed
832
- quiet()
833
- return summary
834
-
835
- def accept_subgraph_proposal(
836
- self,
837
- item_id: str,
838
- *,
839
- review_sink: Any,
840
- workspace_id: Optional[str] = None,
841
- ) -> Dict[str, Any]:
842
- """Merge one accepted proposal into this Brain and approve the item.
843
-
844
- Only the node the reviewer looked at is written. An edge whose other
845
- endpoint is not (yet) in this Brain is *deferred* and reported, because
846
- writing a dangling relation would invent a connection the receiver
847
- cannot see.
848
- """
849
- require_brain_network()
850
- self._require()
851
- item = review_sink.get(item_id, workspace_id=workspace_id)
852
- payload = dict((item or {}).get("payload") or {})
853
- if payload.get("kind") != SUBGRAPH_REVIEW_KIND:
854
- raise ValueError("That review item is not a shared-subgraph proposal.")
855
- node = dict(payload.get("node") or {})
856
- node_id = str(node.get("id") or "")
857
- if not node_id:
858
- raise ValueError("The proposal carries no node id.")
859
- status = str((item or {}).get("status") or "pending")
860
- if status not in _OPEN_REVIEW_STATUSES:
861
- # Import first, approve second would re-write an already-decided
862
- # item; refuse before touching the graph instead.
863
- raise ValueError(f"That proposal is already {status}; nothing was imported.")
864
- chunk_nodes = [dict(n) for n in payload.get("chunk_nodes") or []]
865
- if workspace_id:
866
- node = _scope_node(node, workspace_id)
867
- chunk_nodes = [_scope_node(n, workspace_id) for n in chunk_nodes]
868
- # The chunk rows land in the same write, so their nodes count as
869
- # present even though this Brain has not seen them yet.
870
- writing = {node_id} | {str(n.get("id")) for n in chunk_nodes}
871
- kept: List[Dict[str, Any]] = []
872
- deferred: List[Dict[str, Any]] = []
873
- for edge in payload.get("edges") or []:
874
- endpoints = {str(edge.get("from_node")), str(edge.get("to_node"))}
875
- outside = endpoints - writing
876
- if all(self._node_present(other) for other in outside):
877
- kept.append(edge)
878
- else:
879
- deferred.append({
880
- "from": edge.get("from_node"),
881
- "to": edge.get("to_node"),
882
- "type": edge.get("type"),
883
- "reason": "the other node is not in this Brain yet",
884
- })
885
- result = self._kg.import_graph_data(
886
- {
887
- "header": {"graph_schema_version": payload.get("graph_schema_version")},
888
- "nodes": [node, *chunk_nodes],
889
- "edges": kept,
890
- "chunks": list(payload.get("chunks") or []),
891
- "knowledge_sources": [],
892
- "provenance": list(payload.get("provenance") or []),
893
- },
894
- mode="merge",
895
- )
896
- approved = review_sink.approve(item_id, workspace_id=workspace_id)
897
- return {
898
- "status": "accepted",
899
- "item_id": item_id,
900
- "node_id": node_id,
901
- "imported": result,
902
- "edges_written": len(kept),
903
- "edges_deferred": deferred,
904
- "origin": (item.get("provenance") or {}).get("origin"),
905
- "review_status": approved.get("status"),
906
- }
907
-
908
- def _node_present(self, node_id: str) -> bool:
909
- try:
910
- self._kg.get_node(node_id)
911
- except Exception: # noqa: BLE001 — "not found" is the answer, not a failure
912
- return False
913
- return True
914
-
915
- # ── binary backup / restore ──────────────────────────────────────────────
916
- def backup(self, dest_path=None) -> Dict[str, Any]:
917
- self._require()
918
- self._exports_dir.mkdir(parents=True, exist_ok=True)
919
- dest = Path(dest_path) if dest_path else self._exports_dir / f"kg-backup-{_stamp()}.zip"
920
- with tempfile.TemporaryDirectory() as tmp_s:
921
- tmp = Path(tmp_s)
922
- db_copy = tmp / "knowledge_graph.sqlite"
923
- self._kg.backup_database(db_copy)
924
- manifest = {
925
- "format": BACKUP_FORMAT,
926
- "format_version": FORMAT_VERSION,
927
- **self._kg.schema_versions(),
928
- "created_at": utc_now_iso(),
929
- "db_sha256": _sha256_file(db_copy),
930
- "has_blobs": Path(self._kg.blob_dir).exists(),
931
- }
932
- with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as zf:
933
- zf.write(db_copy, "knowledge_graph.sqlite")
934
- zf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
935
- blob_dir = Path(self._kg.blob_dir)
936
- if blob_dir.exists():
937
- for f in blob_dir.rglob("*"):
938
- if f.is_file():
939
- zf.write(f, f"blobs/{f.relative_to(blob_dir)}")
940
- return {"path": str(dest), "bytes": dest.stat().st_size, "manifest": manifest}
941
-
942
- def restore(
943
- self,
944
- archive_path,
945
- *,
946
- verify: bool = True,
947
- dry_run: bool = False,
948
- confirm: bool = False,
949
- ) -> Dict[str, Any]:
950
- self._require()
951
- archive = Path(archive_path)
952
- if not archive.exists():
953
- raise FileNotFoundError(f"Backup archive not found: {archive}")
954
- if not dry_run and not confirm:
955
- raise ValueError("Explicit confirmation is required before restoring a Knowledge Graph backup.")
956
- with zipfile.ZipFile(archive) as zf:
957
- names = zf.namelist()
958
- _safe_zip_names(names)
959
- if "knowledge_graph.sqlite" not in names:
960
- raise ValueError("Archive is missing knowledge_graph.sqlite.")
961
- manifest = json.loads(zf.read("manifest.json")) if "manifest.json" in names else {}
962
- with tempfile.TemporaryDirectory() as tmp_s:
963
- tmp = Path(tmp_s)
964
- zf.extractall(tmp)
965
- db_src = tmp / "knowledge_graph.sqlite"
966
- if verify and manifest.get("db_sha256"):
967
- if _sha256_file(db_src) != manifest["db_sha256"]:
968
- raise ValueError("Backup integrity check failed (db sha256 mismatch).")
969
- if dry_run:
970
- return {
971
- "restored": False,
972
- "dry_run": True,
973
- "verified": True,
974
- "manifest": manifest,
975
- "planned": {
976
- "database": str(self._kg.db_path),
977
- "blobs": str(self._kg.blob_dir),
978
- "archive": str(archive),
979
- },
980
- }
981
- db_dest = Path(self._kg.db_path)
982
- blob_dest = Path(self._kg.blob_dir)
983
- backup_dir = _pre_restore_backup_dir(db_dest)
984
- try:
985
- _replace_sqlite_atomically(db_src, db_dest, backup_dir)
986
- blob_src = tmp / "blobs"
987
- _replace_tree_with_backup(blob_src if blob_src.exists() else None, blob_dest, backup_dir)
988
- except Exception:
989
- # The rollback is best-effort recovery; an I/O slip inside
990
- # it must never mask the restore failure being reported.
991
- try:
992
- _rollback_sqlite_from_backup(db_dest, backup_dir)
993
- except OSError as rollback_exc:
994
- logging.warning(
995
- "pre-restore rollback incomplete (backup kept at %s): %s",
996
- backup_dir, rollback_exc,
997
- )
998
- raise
999
- stats = self._kg.stats()
1000
- return {
1001
- "restored": True,
1002
- "manifest": manifest,
1003
- "pre_restore_backup": str(backup_dir),
1004
- "nodes": sum(stats.get("nodes", {}).values()),
1005
- }
1006
-
1007
- def verify_backup(self, archive_path) -> Dict[str, Any]:
1008
- archive = Path(archive_path)
1009
- if not archive.exists():
1010
- return {"ok": False, "path": str(archive), "errors": [f"Backup archive not found: {archive}"]}
1011
- try:
1012
- with zipfile.ZipFile(archive) as zf:
1013
- names = zf.namelist()
1014
- _safe_zip_names(names)
1015
- if "knowledge_graph.sqlite" not in names:
1016
- raise ValueError("Archive is missing knowledge_graph.sqlite.")
1017
- manifest = json.loads(zf.read("manifest.json")) if "manifest.json" in names else {}
1018
- with tempfile.TemporaryDirectory() as tmp_s:
1019
- tmp = Path(tmp_s)
1020
- zf.extract("knowledge_graph.sqlite", tmp)
1021
- db_src = tmp / "knowledge_graph.sqlite"
1022
- if manifest.get("db_sha256") and _sha256_file(db_src) != manifest["db_sha256"]:
1023
- raise ValueError("Backup integrity check failed (db sha256 mismatch).")
1024
- return {"ok": True, "path": str(archive), "manifest": manifest, "errors": []}
1025
- except (ValueError, zipfile.BadZipFile, OSError, json.JSONDecodeError) as exc:
1026
- return {"ok": False, "path": str(archive), "errors": [str(exc)]}
1027
-
1028
- # ── encrypted .latticebrain archive ───────────────────────────────────
1029
- def encrypted_archive(self, dest_path=None, *, passphrase: str) -> Dict[str, Any]:
1030
- self._require()
1031
- self._exports_dir.mkdir(parents=True, exist_ok=True)
1032
- dest = Path(dest_path) if dest_path else self._exports_dir / f"brain-{_stamp()}.latticebrain"
1033
- metadata = {
1034
- "storage": self.storage_status().get("active", {}),
1035
- "snapshot": self.snapshot_metadata(),
1036
- "device_identity": self._identity.describe() if self._identity is not None else {},
1037
- "provenance": {"exported_at": utc_now_iso(), "source": "kg-portability"},
1038
- }
1039
- archive = EncryptedBrainArchive(
1040
- BrainArchivePaths(
1041
- db_path=Path(self._kg.db_path),
1042
- blob_dir=Path(self._kg.blob_dir),
1043
- data_dir=self._data_dir,
1044
- metadata=metadata,
1045
- )
1046
- )
1047
- return archive.create(dest, passphrase=passphrase)
1048
-
1049
- def inspect_encrypted_archive(self, archive_path, *, passphrase: Optional[str] = None) -> Dict[str, Any]:
1050
- archive = EncryptedBrainArchive(
1051
- BrainArchivePaths(
1052
- db_path=Path(self._kg.db_path),
1053
- blob_dir=Path(self._kg.blob_dir),
1054
- data_dir=self._data_dir,
1055
- )
1056
- )
1057
- return archive.inspect(Path(archive_path), passphrase=passphrase)
1058
-
1059
- def verify_encrypted_archive(self, archive_path, *, passphrase: str) -> Dict[str, Any]:
1060
- archive = EncryptedBrainArchive(
1061
- BrainArchivePaths(
1062
- db_path=Path(self._kg.db_path),
1063
- blob_dir=Path(self._kg.blob_dir),
1064
- data_dir=self._data_dir,
1065
- )
1066
- )
1067
- return archive.verify(Path(archive_path), passphrase=passphrase)
1068
-
1069
- def restore_encrypted_archive(
1070
- self,
1071
- archive_path,
1072
- *,
1073
- passphrase: str,
1074
- dry_run: bool = False,
1075
- confirm: bool = False,
1076
- ) -> Dict[str, Any]:
1077
- self._require()
1078
- archive = EncryptedBrainArchive(
1079
- BrainArchivePaths(
1080
- db_path=Path(self._kg.db_path),
1081
- blob_dir=Path(self._kg.blob_dir),
1082
- data_dir=self._data_dir,
1083
- )
1084
- )
1085
- return archive.restore(
1086
- Path(archive_path),
1087
- passphrase=passphrase,
1088
- target=BrainArchivePaths(
1089
- db_path=Path(self._kg.db_path),
1090
- blob_dir=Path(self._kg.blob_dir),
1091
- data_dir=self._data_dir,
1092
- ),
1093
- dry_run=dry_run,
1094
- confirm=confirm,
1095
- )
1096
-
1097
- def import_encrypted_archive(
1098
- self,
1099
- archive_path,
1100
- *,
1101
- passphrase: str,
1102
- dry_run: bool = False,
1103
- confirm: bool = False,
1104
- ) -> Dict[str, Any]:
1105
- result = self.restore_encrypted_archive(
1106
- archive_path,
1107
- passphrase=passphrase,
1108
- dry_run=dry_run,
1109
- confirm=confirm,
1110
- )
1111
- result["operation"] = "import"
1112
- return result
1113
-
1114
- # ── status surface ───────────────────────────────────────────────────────
1115
- def snapshot_metadata(self) -> Dict[str, Any]:
1116
- if not self.available():
1117
- return {"available": False}
1118
- return {
1119
- "available": True,
1120
- **self._kg.schema_versions(),
1121
- "stats": self._kg.stats(),
1122
- "provenance": self._kg.provenance_stats(),
1123
- "storage": (
1124
- self._kg.storage_engine.capabilities().as_dict()
1125
- if getattr(self._kg, "storage_engine", None) is not None
1126
- else {"engine": "sqlite", "available": True}
1127
- ),
1128
- }
1129
-
1130
- def storage_status(self) -> Dict[str, Any]:
1131
- if not self.available():
1132
- return {"available": False}
1133
- return {
1134
- "available": True,
1135
- "active": (
1136
- self._kg.storage_engine.capabilities().as_dict()
1137
- if getattr(self._kg, "storage_engine", None) is not None
1138
- else {"engine": "sqlite", "available": True}
1139
- ),
1140
- "postgres": PostgresEngine("", schema="lattice_brain").capabilities().as_dict(),
1141
- "backup_health": self.backup_health(),
1142
- }
1143
-
1144
- def backup_health(self) -> Dict[str, Any]:
1145
- self._exports_dir.mkdir(parents=True, exist_ok=True)
1146
- backups = sorted(
1147
- [
1148
- p for p in self._exports_dir.glob("*")
1149
- if p.is_file() and (p.suffix == ".zip" or p.suffix == ".latticebrain")
1150
- ],
1151
- key=lambda p: p.stat().st_mtime,
1152
- reverse=True,
1153
- )
1154
- latest = backups[0] if backups else None
1155
- return {
1156
- "available": True,
1157
- "directory": str(self._exports_dir),
1158
- "count": len(backups),
1159
- "latest": str(latest) if latest else None,
1160
- "latest_bytes": latest.stat().st_size if latest else 0,
1161
- "encrypted_archives": sum(1 for p in backups if p.suffix == ".latticebrain"),
1162
- "zip_backups": sum(1 for p in backups if p.suffix == ".zip"),
1163
- }
1164
-
1165
- def postgres_docker_setup(
1166
- self,
1167
- *,
1168
- consent: bool,
1169
- dry_run: bool = False,
1170
- port: int = 5432,
1171
- ) -> Dict[str, Any]:
1172
- wizard = DockerPostgresWizard(self._data_dir / "postgres", port=port)
1173
- return wizard.start(consent=consent, dry_run=dry_run)
1174
-
1175
- def migrate_sqlite_to_postgres(
1176
- self,
1177
- *,
1178
- dsn: str,
1179
- schema: str = "lattice_brain",
1180
- dry_run: bool = True,
1181
- ) -> Dict[str, Any]:
1182
- self._require()
1183
- if not dsn:
1184
- raise ValueError("Postgres DSN is required for SQLite to Postgres migration.")
1185
- migrator = SQLiteToPostgresMigrator(
1186
- Path(self._kg.db_path),
1187
- PostgresEngine(dsn, schema=schema),
1188
- )
1189
- if dry_run:
1190
- return migrator.migrate(dry_run=True)
1191
- backup = self.backup()
1192
- verification = self.verify_backup(backup["path"])
1193
- if not verification.get("ok"):
1194
- raise RuntimeError(
1195
- "Pre-migration backup verification failed; Postgres migration was not started: "
1196
- + "; ".join(verification.get("errors") or [])
1197
- )
1198
- result = migrator.migrate(dry_run=False)
1199
- result["pre_migration_backup"] = {
1200
- "path": backup["path"],
1201
- "verified": True,
1202
- "manifest": backup.get("manifest"),
1203
- }
1204
- return result
1205
-
1206
- def recent_ingestions(self, *, limit: int = 50, source_type: Optional[str] = None) -> Dict[str, Any]:
1207
- """Recent provenance records (newest first) for the ingestion-sources UI."""
1208
- if not self.available():
1209
- return {"items": [], "count": 0}
1210
- return self._kg.list_provenance(limit=limit, source_type=source_type)