ltcai 4.2.0 → 4.3.1

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 (51) hide show
  1. package/README.md +28 -21
  2. package/bin/ltcai.js +6 -2
  3. package/docs/CHANGELOG.md +72 -0
  4. package/docs/V4_3_PORTABILITY_ARCHITECTURE.md +69 -0
  5. package/docs/V4_3_PRIVACY_AUDIT.md +60 -0
  6. package/docs/V4_3_PRODUCT_HARDENING_REPORT.md +53 -0
  7. package/docs/V4_3_VALIDATION_REPORT.md +58 -0
  8. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +19 -25
  9. package/frontend/openapi.json +213 -1
  10. package/frontend/src/App.tsx +15 -1
  11. package/frontend/src/api/client.ts +26 -1
  12. package/frontend/src/api/openapi.ts +268 -0
  13. package/frontend/src/pages/Act.tsx +63 -2
  14. package/frontend/src/pages/Library.tsx +9 -3
  15. package/frontend/src/pages/System.tsx +58 -0
  16. package/lattice_brain/__init__.py +1 -1
  17. package/lattice_brain/archive.py +360 -47
  18. package/lattice_brain/storage/sqlite.py +15 -2
  19. package/latticeai/__init__.py +1 -1
  20. package/latticeai/api/admin.py +11 -0
  21. package/latticeai/api/agents.py +3 -1
  22. package/latticeai/api/models.py +66 -18
  23. package/latticeai/api/portability.py +59 -2
  24. package/latticeai/app_factory.py +9 -0
  25. package/latticeai/brain/projection.py +12 -2
  26. package/latticeai/brain/retrieval.py +10 -0
  27. package/latticeai/brain/store.py +6 -1
  28. package/latticeai/core/config.py +4 -2
  29. package/latticeai/core/marketplace.py +1 -1
  30. package/latticeai/core/multi_agent.py +1 -1
  31. package/latticeai/core/product_hardening.py +218 -0
  32. package/latticeai/core/workspace_os.py +1 -1
  33. package/latticeai/services/agent_runtime.py +52 -12
  34. package/latticeai/services/kg_portability.py +147 -4
  35. package/latticeai/services/model_runtime.py +83 -2
  36. package/ltcai_cli.py +16 -4
  37. package/package.json +5 -4
  38. package/requirements.txt +17 -0
  39. package/scripts/clean_release_artifacts.mjs +27 -0
  40. package/scripts/lint_frontend.mjs +5 -0
  41. package/scripts/validate_release_artifacts.py +10 -0
  42. package/src-tauri/Cargo.lock +1 -1
  43. package/src-tauri/Cargo.toml +1 -1
  44. package/src-tauri/src/main.rs +356 -24
  45. package/src-tauri/tauri.conf.json +20 -1
  46. package/static/app/asset-manifest.json +5 -5
  47. package/static/app/assets/{index-C_HAkbAg.js → index-BhPuj8rT.js} +45 -45
  48. package/static/app/assets/index-BhPuj8rT.js.map +1 -0
  49. package/static/app/assets/{index-CDjiH_se.css → index-yZswHE3d.css} +1 -1
  50. package/static/app/index.html +2 -2
  51. package/static/app/assets/index-C_HAkbAg.js.map +0 -1
@@ -1,8 +1,16 @@
1
- """Encrypted .latticebrain archive support."""
1
+ """Encrypted .latticebrain archive support.
2
+
3
+ The archive is intentionally self-contained and local-only: the encrypted
4
+ payload holds the SQLite brain, blob store, portable JSON state, workspace
5
+ export bundles when present, and public metadata needed to inspect/verify/
6
+ restore on another machine without contacting a service.
7
+ """
2
8
 
3
9
  from __future__ import annotations
4
10
 
5
11
  import base64
12
+ import hashlib
13
+ import io
6
14
  import json
7
15
  import os
8
16
  import shutil
@@ -10,8 +18,8 @@ import tempfile
10
18
  import zipfile
11
19
  from dataclasses import dataclass
12
20
  from datetime import datetime, timezone
13
- from pathlib import Path
14
- from typing import Dict, Optional
21
+ from pathlib import Path, PurePosixPath
22
+ from typing import Any, Dict, Iterable, List, Optional
15
23
 
16
24
  from cryptography.hazmat.primitives import hashes
17
25
  from cryptography.hazmat.primitives.ciphers.aead import AESGCM
@@ -20,8 +28,22 @@ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
20
28
 
21
29
 
22
30
  ARCHIVE_FORMAT = "latticebrain.encrypted"
23
- ARCHIVE_VERSION = 1
31
+ ARCHIVE_VERSION = 2
24
32
  KDF_ITERATIONS = 390_000
33
+ PORTABLE_DATA_FILES = (
34
+ "users.json",
35
+ "chat_history.json",
36
+ "workspace_os.json",
37
+ "vpc_config.json",
38
+ "mcp_installs.json",
39
+ "audit_log.json",
40
+ "sso_config.json",
41
+ "hooks.json",
42
+ "invitations.json",
43
+ "agent_registry.json",
44
+ "brain_peers.json",
45
+ )
46
+ PORTABLE_EXPORT_SUFFIXES = (".json", ".zip")
25
47
 
26
48
 
27
49
  def _now() -> str:
@@ -40,10 +62,43 @@ def _derive_key(passphrase: str, salt: bytes) -> bytes:
40
62
  return kdf.derive(passphrase.encode("utf-8"))
41
63
 
42
64
 
65
+ def _sha256_bytes(data: bytes) -> str:
66
+ return hashlib.sha256(data).hexdigest()
67
+
68
+
69
+ def _sha256_file(path: Path) -> str:
70
+ h = hashlib.sha256()
71
+ with open(path, "rb") as fh:
72
+ for block in iter(lambda: fh.read(65536), b""):
73
+ h.update(block)
74
+ return h.hexdigest()
75
+
76
+
77
+ def _safe_json(value: Any) -> Any:
78
+ try:
79
+ json.dumps(value)
80
+ return value
81
+ except TypeError:
82
+ if isinstance(value, dict):
83
+ return {str(k): _safe_json(v) for k, v in value.items()}
84
+ if isinstance(value, (list, tuple)):
85
+ return [_safe_json(v) for v in value]
86
+ return str(value)
87
+
88
+
89
+ def _assert_safe_member(name: str) -> PurePosixPath:
90
+ path = PurePosixPath(name)
91
+ if path.is_absolute() or ".." in path.parts:
92
+ raise ValueError(f"Archive payload contains unsafe path: {name}")
93
+ return path
94
+
95
+
43
96
  @dataclass(frozen=True)
44
97
  class BrainArchivePaths:
45
98
  db_path: Path
46
99
  blob_dir: Optional[Path] = None
100
+ data_dir: Optional[Path] = None
101
+ metadata: Optional[Dict[str, Any]] = None
47
102
 
48
103
 
49
104
  class EncryptedBrainArchive:
@@ -52,6 +107,67 @@ class EncryptedBrainArchive:
52
107
  def __init__(self, paths: BrainArchivePaths) -> None:
53
108
  self.paths = paths
54
109
 
110
+ def _iter_payload_files(self) -> Iterable[tuple[Path, str]]:
111
+ yield Path(self.paths.db_path), "knowledge_graph.sqlite"
112
+ if self.paths.blob_dir and self.paths.blob_dir.exists():
113
+ blob_root = Path(self.paths.blob_dir)
114
+ for file in sorted(blob_root.rglob("*")):
115
+ if file.is_file():
116
+ yield file, f"blobs/{file.relative_to(blob_root).as_posix()}"
117
+ if self.paths.data_dir and Path(self.paths.data_dir).exists():
118
+ data_root = Path(self.paths.data_dir)
119
+ for name in PORTABLE_DATA_FILES:
120
+ file = data_root / name
121
+ if file.is_file():
122
+ yield file, f"data/{name}"
123
+ exports = data_root / "workspace_exports"
124
+ if exports.exists():
125
+ for file in sorted(exports.rglob("*")):
126
+ if not file.is_file():
127
+ continue
128
+ if file.suffix == ".latticebrain":
129
+ continue
130
+ if file.suffix not in PORTABLE_EXPORT_SUFFIXES:
131
+ continue
132
+ yield file, f"workspace_exports/{file.relative_to(exports).as_posix()}"
133
+
134
+ def _build_manifest(self, entries: List[Dict[str, Any]]) -> Dict[str, Any]:
135
+ metadata = _safe_json(self.paths.metadata or {})
136
+ return {
137
+ "format": "latticebrain.payload",
138
+ "format_version": ARCHIVE_VERSION,
139
+ "created_at": _now(),
140
+ "sections": {
141
+ "graph": any(item["path"] == "knowledge_graph.sqlite" for item in entries),
142
+ "blobs": any(item["path"].startswith("blobs/") for item in entries),
143
+ "workspace_state": any(item["path"].startswith("data/") for item in entries),
144
+ "signed_bundles": any(item["path"].startswith("workspace_exports/") for item in entries),
145
+ },
146
+ "metadata": metadata,
147
+ "storage": metadata.get("storage") or {},
148
+ "device_identity": metadata.get("device_identity") or {},
149
+ "provenance": metadata.get("provenance") or {},
150
+ "entries": entries,
151
+ }
152
+
153
+ def _payload_zip_bytes(self) -> tuple[bytes, Dict[str, Any]]:
154
+ entries: List[Dict[str, Any]] = []
155
+ with tempfile.TemporaryDirectory() as tmp_s:
156
+ payload = Path(tmp_s) / "payload.zip"
157
+ with zipfile.ZipFile(payload, "w", zipfile.ZIP_DEFLATED) as zf:
158
+ for src, arcname in self._iter_payload_files():
159
+ _assert_safe_member(arcname)
160
+ zf.write(src, arcname)
161
+ entries.append({
162
+ "path": arcname,
163
+ "bytes": src.stat().st_size,
164
+ "sha256": _sha256_file(src),
165
+ })
166
+ manifest = self._build_manifest(entries)
167
+ manifest_bytes = json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8")
168
+ zf.writestr("manifest.json", manifest_bytes)
169
+ return payload.read_bytes(), manifest
170
+
55
171
  def create(self, destination: Path, *, passphrase: str) -> Dict[str, object]:
56
172
  dest = Path(destination)
57
173
  if dest.suffix != ".latticebrain":
@@ -59,59 +175,226 @@ class EncryptedBrainArchive:
59
175
  if not self.paths.db_path.exists():
60
176
  raise FileNotFoundError(f"Brain database not found: {self.paths.db_path}")
61
177
  dest.parent.mkdir(parents=True, exist_ok=True)
62
- with tempfile.TemporaryDirectory() as tmp_s:
63
- tmp = Path(tmp_s)
64
- payload = tmp / "payload.zip"
65
- with zipfile.ZipFile(payload, "w", zipfile.ZIP_DEFLATED) as zf:
66
- zf.write(self.paths.db_path, "knowledge_graph.sqlite")
67
- if self.paths.blob_dir and self.paths.blob_dir.exists():
68
- for file in self.paths.blob_dir.rglob("*"):
69
- if file.is_file():
70
- zf.write(file, f"blobs/{file.relative_to(self.paths.blob_dir)}")
71
- salt = os.urandom(16)
72
- nonce = os.urandom(12)
73
- key = _derive_key(passphrase, salt)
74
- ciphertext = AESGCM(key).encrypt(nonce, payload.read_bytes(), None)
75
- envelope = {
76
- "format": ARCHIVE_FORMAT,
77
- "format_version": ARCHIVE_VERSION,
78
- "created_at": _now(),
79
- "kdf": {
80
- "name": "PBKDF2HMAC-SHA256",
81
- "iterations": KDF_ITERATIONS,
82
- "salt": base64.b64encode(salt).decode("ascii"),
83
- },
84
- "cipher": {
85
- "name": "AES-256-GCM",
86
- "nonce": base64.b64encode(nonce).decode("ascii"),
87
- },
88
- "payload": base64.b64encode(ciphertext).decode("ascii"),
89
- }
90
- dest.write_text(json.dumps(envelope, indent=2), encoding="utf-8")
91
- return {"path": str(dest), "bytes": dest.stat().st_size, "encrypted": True}
178
+ payload_bytes, manifest = self._payload_zip_bytes()
179
+ salt = os.urandom(16)
180
+ nonce = os.urandom(12)
181
+ key = _derive_key(passphrase, salt)
182
+ ciphertext = AESGCM(key).encrypt(nonce, payload_bytes, None)
183
+ envelope = {
184
+ "format": ARCHIVE_FORMAT,
185
+ "format_version": ARCHIVE_VERSION,
186
+ "created_at": _now(),
187
+ "kdf": {
188
+ "name": "PBKDF2HMAC-SHA256",
189
+ "iterations": KDF_ITERATIONS,
190
+ "salt": base64.b64encode(salt).decode("ascii"),
191
+ },
192
+ "cipher": {
193
+ "name": "AES-256-GCM",
194
+ "nonce": base64.b64encode(nonce).decode("ascii"),
195
+ },
196
+ "payload_sha256": _sha256_bytes(payload_bytes),
197
+ "manifest_summary": {
198
+ "format_version": manifest["format_version"],
199
+ "created_at": manifest["created_at"],
200
+ "sections": manifest["sections"],
201
+ "storage": manifest.get("storage") or {},
202
+ "device_identity": manifest.get("device_identity") or {},
203
+ },
204
+ "payload": base64.b64encode(ciphertext).decode("ascii"),
205
+ }
206
+ dest.write_text(json.dumps(envelope, indent=2), encoding="utf-8")
207
+ return {
208
+ "path": str(dest),
209
+ "bytes": dest.stat().st_size,
210
+ "encrypted": True,
211
+ "format_version": ARCHIVE_VERSION,
212
+ "manifest": {
213
+ "sections": manifest["sections"],
214
+ "storage": manifest.get("storage") or {},
215
+ "entries": len(manifest["entries"]),
216
+ },
217
+ }
92
218
 
93
- def restore(self, source: Path, *, passphrase: str, target: BrainArchivePaths) -> Dict[str, object]:
219
+ def _load_envelope(self, source: Path) -> Dict[str, Any]:
94
220
  src = Path(source)
95
221
  if not src.exists():
96
222
  raise FileNotFoundError(f"Brain archive not found: {src}")
97
- envelope = json.loads(src.read_text(encoding="utf-8"))
223
+ try:
224
+ envelope = json.loads(src.read_text(encoding="utf-8"))
225
+ except json.JSONDecodeError as exc:
226
+ raise ValueError("Archive envelope is not valid JSON.") from exc
98
227
  if envelope.get("format") != ARCHIVE_FORMAT:
99
228
  raise ValueError("Not a .latticebrain encrypted archive.")
100
- salt = base64.b64decode(envelope["kdf"]["salt"])
101
- nonce = base64.b64decode(envelope["cipher"]["nonce"])
102
- ciphertext = base64.b64decode(envelope["payload"])
229
+ version = int(envelope.get("format_version") or 0)
230
+ if version < 1:
231
+ raise ValueError("Archive format version is missing or invalid.")
232
+ if version > ARCHIVE_VERSION:
233
+ raise ValueError(
234
+ f"Archive format version {version} is newer than supported version {ARCHIVE_VERSION}."
235
+ )
236
+ return envelope
237
+
238
+ def inspect(self, source: Path, *, passphrase: Optional[str] = None) -> Dict[str, Any]:
239
+ envelope = self._load_envelope(source)
240
+ summary = {
241
+ "valid_envelope": True,
242
+ "encrypted": True,
243
+ "format": envelope.get("format"),
244
+ "format_version": envelope.get("format_version"),
245
+ "created_at": envelope.get("created_at"),
246
+ "cipher": (envelope.get("cipher") or {}).get("name"),
247
+ "kdf": {
248
+ "name": (envelope.get("kdf") or {}).get("name"),
249
+ "iterations": (envelope.get("kdf") or {}).get("iterations"),
250
+ },
251
+ "manifest_summary": envelope.get("manifest_summary") or {},
252
+ }
253
+ if passphrase:
254
+ verified = self.verify(source, passphrase=passphrase)
255
+ summary["verified"] = verified["ok"]
256
+ summary["manifest"] = verified.get("manifest")
257
+ summary["errors"] = verified.get("errors", [])
258
+ return summary
259
+
260
+ def _decrypt_payload(self, envelope: Dict[str, Any], passphrase: str) -> bytes:
261
+ try:
262
+ salt = base64.b64decode(envelope["kdf"]["salt"])
263
+ nonce = base64.b64decode(envelope["cipher"]["nonce"])
264
+ ciphertext = base64.b64decode(envelope["payload"])
265
+ except Exception as exc:
266
+ raise ValueError("Archive envelope is missing encryption metadata.") from exc
103
267
  key = _derive_key(passphrase, salt)
104
268
  try:
105
- plaintext = AESGCM(key).decrypt(nonce, ciphertext, None)
269
+ payload = AESGCM(key).decrypt(nonce, ciphertext, None)
106
270
  except InvalidTag as exc:
107
271
  raise ValueError("Archive decryption failed; passphrase or archive data is invalid.") from exc
272
+ expected = envelope.get("payload_sha256")
273
+ if expected and _sha256_bytes(payload) != expected:
274
+ raise ValueError("Archive payload integrity check failed (sha256 mismatch).")
275
+ return payload
276
+
277
+ def _read_payload(self, payload: bytes) -> tuple[Dict[str, Any], Dict[str, bytes]]:
278
+ try:
279
+ with zipfile.ZipFile(io.BytesIO(payload)) as zf:
280
+ bad_member = zf.testzip()
281
+ if bad_member:
282
+ raise ValueError(f"Archive payload member is corrupt: {bad_member}")
283
+ names = zf.namelist()
284
+ for name in names:
285
+ _assert_safe_member(name)
286
+ raw = {name: zf.read(name) for name in names if not name.endswith("/")}
287
+ except zipfile.BadZipFile as exc:
288
+ raise ValueError("Archive payload is not a valid ZIP file.") from exc
289
+ if "manifest.json" in raw:
290
+ manifest = json.loads(raw["manifest.json"].decode("utf-8"))
291
+ else:
292
+ manifest = {
293
+ "format": "latticebrain.payload",
294
+ "format_version": 1,
295
+ "created_at": None,
296
+ "sections": {
297
+ "graph": "knowledge_graph.sqlite" in raw,
298
+ "blobs": any(name.startswith("blobs/") for name in raw),
299
+ "workspace_state": False,
300
+ "signed_bundles": False,
301
+ },
302
+ "metadata": {},
303
+ "storage": {},
304
+ "device_identity": {},
305
+ "provenance": {},
306
+ "entries": [
307
+ {"path": name, "bytes": len(data), "sha256": _sha256_bytes(data)}
308
+ for name, data in raw.items()
309
+ if name != "manifest.json"
310
+ ],
311
+ }
312
+ version = int(manifest.get("format_version") or 0)
313
+ if version < 1:
314
+ raise ValueError("Archive manifest format version is missing or invalid.")
315
+ if version > ARCHIVE_VERSION:
316
+ raise ValueError(
317
+ f"Archive manifest version {version} is newer than supported version {ARCHIVE_VERSION}."
318
+ )
319
+ return manifest, raw
320
+
321
+ def verify(self, source: Path, *, passphrase: str) -> Dict[str, Any]:
322
+ try:
323
+ envelope = self._load_envelope(source)
324
+ payload = self._decrypt_payload(envelope, passphrase)
325
+ manifest, raw = self._read_payload(payload)
326
+ if "knowledge_graph.sqlite" not in raw:
327
+ raise ValueError("Archive payload is missing knowledge_graph.sqlite.")
328
+ missing: List[str] = []
329
+ mismatched: List[str] = []
330
+ for entry in manifest.get("entries") or []:
331
+ name = str(entry.get("path") or "")
332
+ if not name or name == "manifest.json":
333
+ continue
334
+ data = raw.get(name)
335
+ if data is None:
336
+ missing.append(name)
337
+ continue
338
+ if entry.get("sha256") and _sha256_bytes(data) != entry["sha256"]:
339
+ mismatched.append(name)
340
+ if missing or mismatched:
341
+ raise ValueError(
342
+ "Archive manifest integrity check failed "
343
+ f"(missing={missing}, mismatched={mismatched})."
344
+ )
345
+ return {
346
+ "ok": True,
347
+ "encrypted": True,
348
+ "format_version": envelope.get("format_version"),
349
+ "manifest": manifest,
350
+ "entries": len([name for name in raw if name != "manifest.json"]),
351
+ "errors": [],
352
+ }
353
+ except (ValueError, FileNotFoundError) as exc:
354
+ return {"ok": False, "encrypted": True, "errors": [str(exc)]}
355
+
356
+ def restore(
357
+ self,
358
+ source: Path,
359
+ *,
360
+ passphrase: str,
361
+ target: BrainArchivePaths,
362
+ dry_run: bool = False,
363
+ confirm: bool = False,
364
+ ) -> Dict[str, object]:
365
+ if not dry_run and not confirm:
366
+ raise ValueError("Explicit confirmation is required before restoring a .latticebrain archive.")
367
+ envelope = self._load_envelope(source)
368
+ payload = self._decrypt_payload(envelope, passphrase)
369
+ manifest, raw = self._read_payload(payload)
370
+ verified = self.verify(source, passphrase=passphrase)
371
+ if not verified["ok"]:
372
+ raise ValueError("; ".join(verified.get("errors") or ["Archive verification failed."]))
373
+ planned = {
374
+ "database": str(target.db_path),
375
+ "blobs": str(target.blob_dir) if target.blob_dir else None,
376
+ "data_dir": str(target.data_dir) if target.data_dir else None,
377
+ "entries": len([name for name in raw if name != "manifest.json"]),
378
+ "sections": manifest.get("sections") or {},
379
+ }
380
+ if dry_run:
381
+ return {
382
+ "restored": False,
383
+ "dry_run": True,
384
+ "verified": True,
385
+ "planned": planned,
386
+ "manifest": manifest,
387
+ }
108
388
  with tempfile.TemporaryDirectory() as tmp_s:
109
389
  tmp = Path(tmp_s)
110
- payload = tmp / "payload.zip"
111
- payload.write_bytes(plaintext)
112
- with zipfile.ZipFile(payload) as zf:
113
- zf.extractall(tmp / "out")
114
- db_src = tmp / "out" / "knowledge_graph.sqlite"
390
+ out = tmp / "out"
391
+ out.mkdir()
392
+ for name, data in raw.items():
393
+ _assert_safe_member(name)
394
+ dest = out / name
395
+ dest.parent.mkdir(parents=True, exist_ok=True)
396
+ dest.write_bytes(data)
397
+ db_src = out / "knowledge_graph.sqlite"
115
398
  if not db_src.exists():
116
399
  raise ValueError("Archive payload is missing knowledge_graph.sqlite.")
117
400
  target.db_path.parent.mkdir(parents=True, exist_ok=True)
@@ -119,7 +402,7 @@ class EncryptedBrainArchive:
119
402
  if sibling.exists():
120
403
  sibling.unlink()
121
404
  shutil.copyfile(db_src, target.db_path)
122
- blobs_src = tmp / "out" / "blobs"
405
+ blobs_src = out / "blobs"
123
406
  if target.blob_dir:
124
407
  if target.blob_dir.exists():
125
408
  shutil.rmtree(target.blob_dir)
@@ -127,7 +410,37 @@ class EncryptedBrainArchive:
127
410
  shutil.copytree(blobs_src, target.blob_dir)
128
411
  else:
129
412
  target.blob_dir.mkdir(parents=True, exist_ok=True)
130
- return {"restored": True, "path": str(target.db_path), "encrypted": True}
413
+ if target.data_dir:
414
+ data_src = out / "data"
415
+ exports_src = out / "workspace_exports"
416
+ target_data = Path(target.data_dir)
417
+ target_data.mkdir(parents=True, exist_ok=True)
418
+ if data_src.exists():
419
+ for file in sorted(data_src.rglob("*")):
420
+ if file.is_file():
421
+ rel = file.relative_to(data_src)
422
+ if rel.as_posix() not in PORTABLE_DATA_FILES:
423
+ continue
424
+ dest = target_data / rel
425
+ dest.parent.mkdir(parents=True, exist_ok=True)
426
+ shutil.copyfile(file, dest)
427
+ if exports_src.exists():
428
+ exports_dest = target_data / "workspace_exports"
429
+ exports_dest.mkdir(parents=True, exist_ok=True)
430
+ for file in sorted(exports_src.rglob("*")):
431
+ if file.is_file():
432
+ rel = file.relative_to(exports_src)
433
+ dest = exports_dest / rel
434
+ dest.parent.mkdir(parents=True, exist_ok=True)
435
+ shutil.copyfile(file, dest)
436
+ return {
437
+ "restored": True,
438
+ "dry_run": False,
439
+ "path": str(target.db_path),
440
+ "encrypted": True,
441
+ "verified": True,
442
+ "manifest": manifest,
443
+ }
131
444
 
132
445
 
133
446
  __all__ = ["BrainArchivePaths", "EncryptedBrainArchive"]
@@ -77,7 +77,12 @@ class SQLiteEngine(StorageEngine):
77
77
  backup_restore=True,
78
78
  migrations=True,
79
79
  encrypted_archives=True,
80
- metadata={"db_path": str(self.db_path), "sqlite_vec_loaded": False},
80
+ metadata={
81
+ "db_path": str(self.db_path),
82
+ "sqlite_vec_loaded": False,
83
+ "vector_mode": "fallback",
84
+ "honest_fallback": "sqlite-vec has not been probed yet; vector search uses the real brute-force cosine fallback until sqlite-vec is loaded.",
85
+ },
81
86
  )
82
87
  # Probe on demand so status is accurate even before the graph opens.
83
88
  try:
@@ -94,7 +99,11 @@ class SQLiteEngine(StorageEngine):
94
99
  return StorageCapabilities(
95
100
  engine=self.name,
96
101
  available=True,
97
- reason=None if self._sqlite_vec_loaded else self._sqlite_vec_reason,
102
+ reason=None if self._sqlite_vec_loaded else (
103
+ f"{self._sqlite_vec_reason}; using real brute-force cosine fallback, not sqlite-vec ANN"
104
+ if self._sqlite_vec_reason
105
+ else "sqlite-vec unavailable; using real brute-force cosine fallback, not sqlite-vec ANN"
106
+ ),
98
107
  vector_backend=vector_backend,
99
108
  vector_available=True,
100
109
  backup_restore=True,
@@ -103,6 +112,10 @@ class SQLiteEngine(StorageEngine):
103
112
  metadata={
104
113
  "db_path": str(self.db_path),
105
114
  "sqlite_vec_loaded": self._sqlite_vec_loaded,
115
+ "sqlite_vec_ann_available": self._sqlite_vec_loaded,
116
+ "vector_mode": "sqlite-vec" if self._sqlite_vec_loaded else "fallback",
117
+ "degraded": not self._sqlite_vec_loaded,
118
+ "honest_fallback": None if self._sqlite_vec_loaded else "Vector search is available through the deterministic brute-force cosine backend. sqlite-vec ANN is unavailable.",
106
119
  },
107
120
  )
108
121
 
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "4.2.0"
3
+ __version__ = "4.3.1"
@@ -56,6 +56,7 @@ def create_admin_router(
56
56
  invite_gate_enabled: bool,
57
57
  default_port: int,
58
58
  policy_matrix: Optional[Callable[[], List[Dict[str, object]]]] = None,
59
+ product_hardening_status: Optional[Callable[[], Dict[str, object]]] = None,
59
60
  ) -> APIRouter:
60
61
  router = APIRouter()
61
62
 
@@ -155,6 +156,16 @@ def create_admin_router(
155
156
  ]
156
157
  }
157
158
 
159
+ @router.get("/admin/product-hardening")
160
+ async def admin_product_hardening(request: Request):
161
+ require_admin(request)
162
+ if product_hardening_status is None:
163
+ return {
164
+ "available": False,
165
+ "reason": "Product hardening status provider is not configured.",
166
+ }
167
+ return product_hardening_status()
168
+
158
169
  @router.get("/vpc/status")
159
170
  async def vpc_status(request: Request):
160
171
  require_user(request)
@@ -46,7 +46,7 @@ def create_agents_router(
46
46
  run_executor: Any = None,
47
47
  ) -> APIRouter:
48
48
  from latticeai.core.multi_agent import AGENT_ROLES, ROLE_AGENT_IDS
49
- from latticeai.services.agent_runtime import AgentRuntime
49
+ from latticeai.services.agent_runtime import AgentRuntime, AgentRuntimeUnavailable
50
50
 
51
51
  # Single AgentRuntime boundary: the router (and via it, the frontend) talks
52
52
  # to this façade instead of reaching into the orchestrator/store directly.
@@ -186,6 +186,8 @@ def create_agents_router(
186
186
  )
187
187
  except ValueError as exc:
188
188
  raise HTTPException(status_code=400, detail=str(exc)) from exc
189
+ except AgentRuntimeUnavailable as exc:
190
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
189
191
  except PermissionError as exc:
190
192
  # A pre_run hook gated this run (e.g. a policy/permission hook).
191
193
  raise HTTPException(status_code=403, detail=str(exc)) from exc