ltcai 11.1.0 → 11.2.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 (118) hide show
  1. package/README.md +53 -53
  2. package/docs/CHANGELOG.md +33 -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/ONBOARDING.md +1 -1
  8. package/docs/OPERATIONS.md +1 -1
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/architecture.md +6 -2
  12. package/docs/kg-schema.md +1 -1
  13. package/lattice_brain/__init__.py +1 -1
  14. package/lattice_brain/gates.py +125 -0
  15. package/lattice_brain/graph/fusion.py +35 -4
  16. package/lattice_brain/graph/projection.py +66 -8
  17. package/lattice_brain/graph/schema.py +9 -0
  18. package/lattice_brain/graph/store.py +9 -0
  19. package/lattice_brain/graph/vector_index/selector.py +32 -2
  20. package/lattice_brain/ingestion.py +175 -27
  21. package/lattice_brain/multimodal.py +525 -5
  22. package/lattice_brain/portability.py +169 -32
  23. package/lattice_brain/runtime/multi_agent.py +1 -1
  24. package/lattice_brain/sealed_box.py +244 -0
  25. package/lattice_brain/synthesis.py +24 -1
  26. package/latticeai/__init__.py +1 -1
  27. package/latticeai/api/brain_intelligence.py +4 -0
  28. package/latticeai/api/chat.py +11 -0
  29. package/latticeai/api/chat_helpers.py +16 -3
  30. package/latticeai/api/chat_hybrid.py +32 -1
  31. package/latticeai/api/features.py +70 -0
  32. package/latticeai/api/local_files.py +102 -0
  33. package/latticeai/api/portability.py +39 -4
  34. package/latticeai/api/review_queue.py +126 -0
  35. package/latticeai/api/search.py +16 -2
  36. package/latticeai/core/agent.py +55 -2
  37. package/latticeai/core/config.py +4 -1
  38. package/latticeai/core/context_builder.py +6 -3
  39. package/latticeai/core/legacy_compatibility.py +1 -1
  40. package/latticeai/core/marketplace.py +1 -1
  41. package/latticeai/core/messages.py +143 -0
  42. package/latticeai/core/model_compat.py +73 -2
  43. package/latticeai/core/workspace_os_constants.py +1 -1
  44. package/latticeai/models/model_providers.py +12 -4
  45. package/latticeai/runtime/build_phases.py +28 -0
  46. package/latticeai/runtime/chat_wiring.py +4 -0
  47. package/latticeai/runtime/feature_toggle_wiring.py +163 -0
  48. package/latticeai/runtime/router_registration.py +11 -0
  49. package/latticeai/services/app_context.py +8 -0
  50. package/latticeai/services/architecture_readiness.py +1 -1
  51. package/latticeai/services/automation_intelligence.py +22 -2
  52. package/latticeai/services/brain_intelligence.py +123 -7
  53. package/latticeai/services/command_center.py +10 -4
  54. package/latticeai/services/feature_toggles.py +502 -0
  55. package/latticeai/services/folder_watch.py +122 -1
  56. package/latticeai/services/hybrid_chat.py +56 -5
  57. package/latticeai/services/interop_bridges.py +978 -0
  58. package/latticeai/services/model_capability_registry.py +434 -261
  59. package/latticeai/services/model_catalog.py +95 -61
  60. package/latticeai/services/model_recommendation.py +18 -11
  61. package/latticeai/services/model_runtime.py +1 -1
  62. package/latticeai/services/multimodal_ports.py +26 -1
  63. package/latticeai/services/obsidian_bridge.py +16 -25
  64. package/latticeai/services/product_readiness.py +1 -1
  65. package/latticeai/services/search_service.py +149 -2
  66. package/latticeai/services/tool_dispatch.py +4 -0
  67. package/latticeai/setup/auto_setup.py +27 -30
  68. package/latticeai/setup/wizard.py +77 -44
  69. package/package.json +1 -1
  70. package/scripts/check_current_release_docs.mjs +1 -1
  71. package/scripts/check_server_i18n.mjs +1 -0
  72. package/scripts/release_screen_claims.json +13 -0
  73. package/scripts/verify_hf_model_registry.py +253 -218
  74. package/src-tauri/Cargo.lock +1 -1
  75. package/src-tauri/Cargo.toml +1 -1
  76. package/src-tauri/tauri.conf.json +1 -1
  77. package/static/app/asset-manifest.json +37 -37
  78. package/static/app/assets/{Act-D0HWqtn0.js → Act-AWf0SAKp.js} +1 -1
  79. package/static/app/assets/{AdminConsole-D-QDW-A4.js → AdminConsole-D0u8Tiyj.js} +1 -1
  80. package/static/app/assets/{Brain-CzCsI1mi.js → Brain-tuhI4sOC.js} +1 -1
  81. package/static/app/assets/BrainHome-Ts7G_Ila.js +2 -0
  82. package/static/app/assets/{BrainSignals-2dHQNkns.js → BrainSignals-jMYgQ2Ar.js} +1 -1
  83. package/static/app/assets/{Capture-CT8v1StE.js → Capture-CqOSzyPr.js} +1 -1
  84. package/static/app/assets/{CommandPalette-DoLXC2KH.js → CommandPalette-DC0Bzh-I.js} +1 -1
  85. package/static/app/assets/{Library-DDoxFE5c.js → Library-CX-bbhmK.js} +1 -1
  86. package/static/app/assets/{LivingBrain-BXMWIK_2.js → LivingBrain-DBwhto14.js} +1 -1
  87. package/static/app/assets/{ProductFlow-DOYf7JIs.js → ProductFlow-BHA2cfKI.js} +1 -1
  88. package/static/app/assets/{ReviewCard-COQsqidK.js → ReviewCard-BUhCKRNM.js} +1 -1
  89. package/static/app/assets/{System-BRllvYXd.js → System-Bu2t5hn1.js} +1 -1
  90. package/static/app/assets/arrow-left-Dzwa5zRb.js +1 -0
  91. package/static/app/assets/{bot-4BvN07ux.js → bot-Cia42c2h.js} +1 -1
  92. package/static/app/assets/brain-DJMoqrwx.js +1 -0
  93. package/static/app/assets/{button-CDjtnAoU.js → button-2j2Ijzgq.js} +1 -1
  94. package/static/app/assets/{circle-pause-D_RMn7tp.js → circle-pause-BEFeWpVW.js} +1 -1
  95. package/static/app/assets/{circle-play-B5OpB8ae.js → circle-play-ujXMcHxl.js} +1 -1
  96. package/static/app/assets/{cpu-BIlWInHf.js → cpu-k4awryFq.js} +1 -1
  97. package/static/app/assets/{download-BtjXfL3z.js → download-DFbLJ_ig.js} +1 -1
  98. package/static/app/assets/{folder-open-DefMpxI2.js → folder-open-7y_b6xkM.js} +1 -1
  99. package/static/app/assets/{hard-drive-BQ8NZVkw.js → hard-drive-Bidh02Kr.js} +1 -1
  100. package/static/app/assets/{index-0AvoEBzJ.js → index-BpYkzcVm.js} +3 -3
  101. package/static/app/assets/{index-vtEfYvQY.css → index-DwDl9-8Y.css} +1 -1
  102. package/static/app/assets/{input-B_5ZJ9oy.js → input-DSlJJxRs.js} +1 -1
  103. package/static/app/assets/{permissionCopy-BqZ5tsgL.js → permissionCopy-Bpb83Hx9.js} +1 -1
  104. package/static/app/assets/{primitives-CVwew78r.js → primitives-BCx6TvfG.js} +1 -1
  105. package/static/app/assets/search-Cgy8cCFJ.js +1 -0
  106. package/static/app/assets/{share-2-D5zg_0fY.js → share-2-BH1M-WNi.js} +1 -1
  107. package/static/app/assets/{shield-alert-B5pZzkUb.js → shield-alert-BlKdBXcG.js} +1 -1
  108. package/static/app/assets/{textarea-nEVIweKY.js → textarea-CCWbUfFB.js} +1 -1
  109. package/static/app/assets/{useFocusTrap-Cm99AHlz.js → useFocusTrap-YdHQ7pJ1.js} +1 -1
  110. package/static/app/assets/{useQuery-Dm__N6bL.js → useQuery-CXQiwbVT.js} +1 -1
  111. package/static/app/assets/{utils-DcDMoZIe.js → utils-zqPZJxdx.js} +2 -2
  112. package/static/app/assets/{workspace-LtRRSKTf.js → workspace-DXTihhfU.js} +1 -1
  113. package/static/app/index.html +4 -4
  114. package/static/sw.js +1 -1
  115. package/static/app/assets/BrainHome-Btns-_TA.js +0 -2
  116. package/static/app/assets/arrow-left-DnyMzss-.js +0 -1
  117. package/static/app/assets/brain-uMb_5hnO.js +0 -1
  118. package/static/app/assets/search-DkhnOKZt.js +0 -1
@@ -38,7 +38,14 @@ from .archive import (
38
38
  EncryptedBrainArchive,
39
39
  _derive_key,
40
40
  )
41
+ from .gates import FeatureGate
41
42
  from .quiet import quiet
43
+ from .sealed_box import (
44
+ SEALED_BOX_ALGORITHM,
45
+ load_recipient_identity,
46
+ public_key_fingerprint,
47
+ seal,
48
+ )
42
49
  from .storage import (
43
50
  DockerPostgresWizard,
44
51
  PostgresEngine,
@@ -64,6 +71,9 @@ SUBGRAPH_ARCHIVE_FORMAT = "latticebrain.subgraph"
64
71
  #: Brain is a network act, and local-first means those are never on by default.
65
72
  BRAIN_NETWORK_ENV = "LATTICEAI_BRAIN_NETWORK"
66
73
  _TRUTHY = frozenset({"1", "true", "yes", "on"})
74
+ #: How a bundle can be encrypted. ``passphrase`` needs a secret agreed out of
75
+ #: band; ``recipient_public_key`` needs nothing secret to travel at all.
76
+ ENCRYPTION_MODES = ("passphrase", "recipient_public_key")
67
77
  #: A received bundle becomes review items, never a merge. The cap keeps one
68
78
  #: peer from flooding the inbox; what it dropped is reported, not hidden.
69
79
  SUBGRAPH_PROPOSAL_CAP = 200
@@ -82,6 +92,17 @@ BRAIN_NETWORK_DISABLED_DETAIL = (
82
92
  )
83
93
 
84
94
 
95
+ #: The share gate, resolved when it is asked. The environment variable is still
96
+ #: the default answer — an untouched install behaves exactly as it did — but a
97
+ #: settings surface can now bind a resolver and move it without a restart.
98
+ BRAIN_NETWORK_GATE = FeatureGate(
99
+ BRAIN_NETWORK_ENV,
100
+ default=False,
101
+ name="brain_network",
102
+ detail=BRAIN_NETWORK_DISABLED_DETAIL,
103
+ )
104
+
105
+
85
106
  class BrainNetworkDisabled(PermissionError):
86
107
  """Raised when a share surface is used while the opt-in flag is off."""
87
108
 
@@ -91,7 +112,7 @@ class BrainNetworkDisabled(PermissionError):
91
112
 
92
113
  def brain_network_enabled() -> bool:
93
114
  """True only when the operator explicitly opted in (default: off)."""
94
- return os.getenv(BRAIN_NETWORK_ENV, "").strip().lower() in _TRUTHY
115
+ return BRAIN_NETWORK_GATE.enabled()
95
116
 
96
117
 
97
118
  def require_brain_network() -> None:
@@ -365,6 +386,41 @@ class KGPortabilityService:
365
386
  # importable locally (origin='unsigned-legacy') — signatures are
366
387
  # mandatory only on the Brain Network peer path.
367
388
  self._identity = device_identity
389
+ # The receiving X25519 key is created lazily: a Brain that never asks
390
+ # anyone to seal anything to it should not be minting key material on
391
+ # every startup (v11.2.0).
392
+ self._recipient: Any = None
393
+ self._recipient_loaded = False
394
+
395
+ # ── recipient key (v11.2.0 sealed-box receipt) ───────────────────────────
396
+ def _recipient_identity(self) -> Any:
397
+ """This Brain's X25519 receiving key, created on first use.
398
+
399
+ ``None`` when the data directory cannot hold one — reported as a state
400
+ by :meth:`recipient_key`, never raised into a status read.
401
+ """
402
+ if not self._recipient_loaded:
403
+ self._recipient = load_recipient_identity(self._data_dir)
404
+ self._recipient_loaded = True
405
+ return self._recipient
406
+
407
+ def recipient_key(self) -> Dict[str, Any]:
408
+ """The public half to hand a sender, so they can seal a bundle to us.
409
+
410
+ Publishing this is safe by construction: a public key encrypts, it does
411
+ not decrypt, and it says nothing about what this Brain contains.
412
+ """
413
+ require_brain_network()
414
+ identity = self._recipient_identity()
415
+ if identity is None:
416
+ return {
417
+ "available": False,
418
+ "detail": (
419
+ "a receiving key could not be created in the data directory; "
420
+ "sealed bundles cannot be opened on this machine"
421
+ ),
422
+ }
423
+ return {"available": True, **identity.describe()}
368
424
 
369
425
  def available(self) -> bool:
370
426
  return self._enable and self._kg is not None
@@ -469,11 +525,14 @@ class KGPortabilityService:
469
525
  "signing": self._identity is not None,
470
526
  "device": self._share_device(),
471
527
  "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,
528
+ # Stated rather than implied: a bundle is always signed by this
529
+ # device, and encrypted either with a shared passphrase or — since
530
+ # 11.2.0 — to the recipient's own public key, so nothing secret has
531
+ # to travel ahead of it.
532
+ "encryption": list(ENCRYPTION_MODES),
533
+ "recipient_public_key_encryption": True,
534
+ "sealed_box_algorithm": SEALED_BOX_ALGORITHM,
535
+ "gate": BRAIN_NETWORK_GATE.describe(),
477
536
  "detail": None if enabled else BRAIN_NETWORK_DISABLED_DETAIL,
478
537
  }
479
538
 
@@ -607,17 +666,28 @@ class KGPortabilityService:
607
666
  self,
608
667
  dest_path=None,
609
668
  *,
610
- passphrase: str,
669
+ passphrase: Optional[str] = None,
670
+ recipient_public_key: Optional[str] = None,
611
671
  **selection: Any,
612
672
  ) -> Dict[str, Any]:
613
673
  """Write the signed subgraph as an encrypted ``.latticebrain`` file.
614
674
 
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.
675
+ Exactly one recipient mechanism, named explicitly:
676
+
677
+ * ``passphrase`` — the 11.1.0 mechanism, identical to the whole-Brain
678
+ archive (PBKDF2-SHA256 → AES-256-GCM). Simple, and it means a secret
679
+ has to reach the receiver through some other channel first.
680
+ * ``recipient_public_key`` — an X25519 sealed box (11.2.0). The
681
+ receiver publishes a public key; nothing secret travels at all.
682
+
683
+ Supplying both is refused rather than silently preferring one: which
684
+ key opens a bundle is not a detail to guess at. A different ``format``
685
+ from the whole-Brain archive keeps the two from ever being confused,
686
+ and the header and signature stay *outside* the ciphertext on purpose —
687
+ a recipient can see who sent a bundle, and how big it is, before
688
+ deciding to open it.
620
689
  """
690
+ mode = self._encryption_mode(passphrase, recipient_public_key)
621
691
  artifact = self.export_subgraph(**selection)
622
692
  self._exports_dir.mkdir(parents=True, exist_ok=True)
623
693
  dest = Path(dest_path) if dest_path else self._exports_dir / f"subgraph-{_stamp()}.latticebrain"
@@ -625,36 +695,77 @@ class KGPortabilityService:
625
695
  dest = dest.with_suffix(".latticebrain")
626
696
  body = {key: value for key, value in artifact.items() if key not in {"header", "signature"}}
627
697
  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 = {
698
+ envelope: Dict[str, Any] = {
632
699
  "format": SUBGRAPH_ARCHIVE_FORMAT,
633
700
  "format_version": SUBGRAPH_FORMAT_VERSION,
634
701
  "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")},
702
+ "encryption": mode,
641
703
  "header": artifact["header"],
642
704
  "signature": artifact["signature"],
643
- "payload": base64.b64encode(ciphertext).decode("ascii"),
644
705
  }
706
+ recipient: Optional[str] = None
707
+ if mode == "recipient_public_key":
708
+ sealed = seal(plaintext, recipient_public_key=str(recipient_public_key))
709
+ recipient = str(sealed["recipient_fingerprint"])
710
+ envelope["sealed_box"] = sealed
711
+ else:
712
+ salt = os.urandom(16)
713
+ nonce = os.urandom(12)
714
+ ciphertext = AESGCM(_derive_key(str(passphrase), salt)).encrypt(nonce, plaintext, None)
715
+ envelope["kdf"] = {
716
+ "name": "PBKDF2HMAC-SHA256",
717
+ "iterations": KDF_ITERATIONS,
718
+ "salt": base64.b64encode(salt).decode("ascii"),
719
+ }
720
+ envelope["cipher"] = {
721
+ "name": "AES-256-GCM",
722
+ "nonce": base64.b64encode(nonce).decode("ascii"),
723
+ }
724
+ envelope["payload"] = base64.b64encode(ciphertext).decode("ascii")
645
725
  dest.parent.mkdir(parents=True, exist_ok=True)
646
726
  dest.write_text(json.dumps(envelope, ensure_ascii=False, indent=2), encoding="utf-8")
647
727
  return {
648
728
  "path": str(dest),
649
729
  "bytes": dest.stat().st_size,
650
730
  "encrypted": True,
731
+ "encryption": mode,
732
+ "recipient_fingerprint": recipient,
651
733
  "format": SUBGRAPH_ARCHIVE_FORMAT,
652
734
  "header": artifact["header"],
653
735
  "counts": artifact["header"]["counts"],
654
736
  }
655
737
 
656
- def read_subgraph_archive(self, path, *, passphrase: str) -> Dict[str, Any]:
657
- """Decrypt an encrypted subgraph bundle back into a signed artifact."""
738
+ @staticmethod
739
+ def _encryption_mode(
740
+ passphrase: Optional[str], recipient_public_key: Optional[str]
741
+ ) -> str:
742
+ """``passphrase`` | ``recipient_public_key`` — exactly one, validated."""
743
+ has_passphrase = bool(str(passphrase or "").strip())
744
+ wanted_key = str(recipient_public_key or "").strip()
745
+ if has_passphrase and wanted_key:
746
+ raise ValueError(
747
+ "Choose one: a passphrase, or a recipient public key. "
748
+ "A bundle sealed both ways would hide which key really opens it."
749
+ )
750
+ if wanted_key:
751
+ public_key_fingerprint(wanted_key) # validates; raises on garbage
752
+ return "recipient_public_key"
753
+ if has_passphrase:
754
+ return "passphrase"
755
+ raise ValueError(
756
+ "A subgraph bundle must be encrypted: supply a passphrase or the "
757
+ "recipient's public key."
758
+ )
759
+
760
+ def read_subgraph_archive(
761
+ self, path, *, passphrase: Optional[str] = None
762
+ ) -> Dict[str, Any]:
763
+ """Decrypt an encrypted subgraph bundle back into a signed artifact.
764
+
765
+ The envelope says how it was sealed, so the reader never has to guess:
766
+ a passphrase bundle needs the passphrase, a sealed-box bundle needs
767
+ this Brain's own receiving key and no passphrase at all.
768
+ """
658
769
  require_brain_network()
659
770
  source = Path(path)
660
771
  if not source.exists():
@@ -665,6 +776,36 @@ class KGPortabilityService:
665
776
  raise ValueError("Subgraph bundle is not valid JSON.") from exc
666
777
  if envelope.get("format") != SUBGRAPH_ARCHIVE_FORMAT:
667
778
  raise ValueError("Not a .latticebrain subgraph bundle.")
779
+ if envelope.get("encryption") == "recipient_public_key":
780
+ plaintext = self._unseal_bundle(envelope)
781
+ else:
782
+ plaintext = self._decrypt_with_passphrase(envelope, passphrase)
783
+ body = json.loads(plaintext.decode("utf-8"))
784
+ return {
785
+ "header": envelope.get("header") or {},
786
+ **body,
787
+ "signature": envelope.get("signature") or {},
788
+ }
789
+
790
+ def _unseal_bundle(self, envelope: Dict[str, Any]) -> bytes:
791
+ """Open a sealed box with this Brain's receiving key, or say why not."""
792
+ identity = self._recipient_identity()
793
+ if identity is None:
794
+ raise ValueError(
795
+ "This bundle is sealed to a public key, but no receiving key "
796
+ "could be loaded on this machine."
797
+ )
798
+ block = envelope.get("sealed_box")
799
+ if not isinstance(block, dict):
800
+ raise ValueError("Subgraph bundle is missing encryption metadata.")
801
+ return bytes(identity.unseal(block))
802
+
803
+ @staticmethod
804
+ def _decrypt_with_passphrase(
805
+ envelope: Dict[str, Any], passphrase: Optional[str]
806
+ ) -> bytes:
807
+ if not str(passphrase or "").strip():
808
+ raise ValueError("This bundle is passphrase-encrypted; a passphrase is required.")
668
809
  try:
669
810
  salt = base64.b64decode(envelope["kdf"]["salt"])
670
811
  nonce = base64.b64decode(envelope["cipher"]["nonce"])
@@ -672,17 +813,13 @@ class KGPortabilityService:
672
813
  except (KeyError, TypeError, ValueError) as exc:
673
814
  raise ValueError("Subgraph bundle is missing encryption metadata.") from exc
674
815
  try:
675
- plaintext = AESGCM(_derive_key(passphrase, salt)).decrypt(nonce, ciphertext, None)
816
+ return bytes(
817
+ AESGCM(_derive_key(str(passphrase), salt)).decrypt(nonce, ciphertext, None)
818
+ )
676
819
  except InvalidTag as exc:
677
820
  raise ValueError(
678
821
  "Subgraph decryption failed; the passphrase or the bundle is invalid."
679
822
  ) 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
823
 
687
824
  def verify_subgraph(self, artifact: Dict[str, Any]) -> Dict[str, Any]:
688
825
  """Signature + payload-digest verdict for a received bundle.
@@ -49,7 +49,7 @@ from typing import Any, Callable, Dict, List, Optional
49
49
  from ..utils import now_iso as _now
50
50
  from .contracts import multi_agent_contract
51
51
 
52
- MULTI_AGENT_VERSION = "11.1.0"
52
+ MULTI_AGENT_VERSION = "11.2.0"
53
53
 
54
54
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
55
55
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -0,0 +1,244 @@
1
+ """Sealing a bundle to a recipient's public key (v11.2.0).
2
+
3
+ Through 11.1.0 a shared subgraph was encrypted with a passphrase, which means
4
+ the sender and the receiver must first agree on a secret over some *other*
5
+ channel — and whatever that channel is, the passphrase is now in it. This
6
+ module removes that step: the receiver publishes an X25519 public key, the
7
+ sender seals a bundle to it, and only the holder of the matching private key
8
+ can open it. Nothing secret ever travels.
9
+
10
+ The construction is the standard sealed box, assembled from primitives
11
+ ``cryptography`` already ships (it is a core dependency; nothing new is added):
12
+
13
+ 1. the sender generates a **single-use ephemeral X25519 keypair**;
14
+ 2. ``ephemeral_private × recipient_public`` yields a shared secret only the two
15
+ of them can compute;
16
+ 3. **HKDF-SHA256** stretches that secret into a 256-bit key, with both public
17
+ keys mixed into ``info`` so a key derived for one recipient can never be
18
+ reused for another;
19
+ 4. **AES-256-GCM** encrypts the payload under it.
20
+
21
+ Forward secrecy comes free: the ephemeral private key is discarded the moment
22
+ the box is sealed, so a later compromise of the recipient's long-term key still
23
+ does not decrypt an already-sent bundle — nothing else in the envelope can
24
+ reconstruct the shared secret.
25
+
26
+ The sender's Ed25519 signature over the bundle header is **untouched** by all
27
+ of this. Signing says who wrote it; sealing says who may read it. Collapsing
28
+ the two into one keypair is the usual way to end up with neither.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import base64
34
+ import hashlib
35
+ import os
36
+ from pathlib import Path
37
+ from typing import Any, Dict, Optional
38
+
39
+ from cryptography.exceptions import InvalidTag
40
+ from cryptography.hazmat.primitives import hashes, serialization
41
+ from cryptography.hazmat.primitives.asymmetric.x25519 import (
42
+ X25519PrivateKey,
43
+ X25519PublicKey,
44
+ )
45
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
46
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
47
+
48
+ #: Named in the envelope so a future construction can be told apart from this
49
+ #: one instead of being silently mis-decrypted.
50
+ SEALED_BOX_ALGORITHM = "x25519-hkdf-sha256-aes256gcm"
51
+ #: Domain separation for the KDF — this key schedule is for subgraph bundles
52
+ #: and nothing else.
53
+ HKDF_INFO_PREFIX = b"latticeai.subgraph.sealed-box.v1"
54
+ KEY_BYTES = 32
55
+ NONCE_BYTES = 12
56
+ SALT_BYTES = 16
57
+ #: Filename of the recipient key under the data directory (0600, like the
58
+ #: device identity's key file).
59
+ RECIPIENT_KEY_FILENAME = "subgraph_recipient.key"
60
+
61
+
62
+ def _b64(data: bytes) -> str:
63
+ return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
64
+
65
+
66
+ def _unb64(text: str) -> bytes:
67
+ padded = str(text) + "=" * (-len(str(text)) % 4)
68
+ return base64.urlsafe_b64decode(padded.encode("ascii"))
69
+
70
+
71
+ def _raw_public(key: X25519PublicKey) -> bytes:
72
+ return key.public_bytes(
73
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw
74
+ )
75
+
76
+
77
+ def public_key_fingerprint(public_key_b64: str) -> str:
78
+ """Human-comparable fingerprint of an X25519 public key.
79
+
80
+ Raises ``ValueError`` when the input is not a usable key — callers use this
81
+ as the validation gate before sealing anything to a pasted string.
82
+ """
83
+ try:
84
+ raw = _unb64(public_key_b64)
85
+ X25519PublicKey.from_public_bytes(raw)
86
+ except Exception as exc: # noqa: BLE001 — any parse failure is one answer
87
+ raise ValueError(f"not a valid X25519 public key: {exc}") from exc
88
+ digest = hashlib.sha256(raw).hexdigest()
89
+ return ":".join(digest[i : i + 4] for i in range(0, 16, 4))
90
+
91
+
92
+ def _derive(shared: bytes, salt: bytes, ephemeral: bytes, recipient: bytes) -> bytes:
93
+ return HKDF(
94
+ algorithm=hashes.SHA256(),
95
+ length=KEY_BYTES,
96
+ salt=salt,
97
+ info=HKDF_INFO_PREFIX + b"|" + ephemeral + b"|" + recipient,
98
+ ).derive(shared)
99
+
100
+
101
+ def seal(plaintext: bytes, *, recipient_public_key: str) -> Dict[str, Any]:
102
+ """Encrypt ``plaintext`` so only the recipient's private key can open it.
103
+
104
+ Returns the JSON-safe crypto block that goes in the bundle envelope. The
105
+ recipient's own public key is echoed back into it so a receiver holding
106
+ several keys can tell at a glance whether this box was meant for them —
107
+ without trying to decrypt it, and without the sender having to be asked.
108
+ """
109
+ try:
110
+ recipient_raw = _unb64(recipient_public_key)
111
+ peer = X25519PublicKey.from_public_bytes(recipient_raw)
112
+ except Exception as exc: # noqa: BLE001 — a bad key is a caller error, said plainly
113
+ raise ValueError(f"not a valid X25519 public key: {exc}") from exc
114
+ ephemeral = X25519PrivateKey.generate()
115
+ ephemeral_raw = _raw_public(ephemeral.public_key())
116
+ salt = os.urandom(SALT_BYTES)
117
+ nonce = os.urandom(NONCE_BYTES)
118
+ key = _derive(ephemeral.exchange(peer), salt, ephemeral_raw, recipient_raw)
119
+ ciphertext = AESGCM(key).encrypt(nonce, plaintext, None)
120
+ return {
121
+ "algorithm": SEALED_BOX_ALGORITHM,
122
+ "ephemeral_public_key": _b64(ephemeral_raw),
123
+ "recipient_public_key": _b64(recipient_raw),
124
+ "recipient_fingerprint": public_key_fingerprint(recipient_public_key),
125
+ "salt": _b64(salt),
126
+ "nonce": _b64(nonce),
127
+ "ciphertext": _b64(ciphertext),
128
+ }
129
+
130
+
131
+ def unseal(block: Dict[str, Any], private_key: X25519PrivateKey) -> bytes:
132
+ """Open a sealed box with the recipient's private key.
133
+
134
+ Fail-closed and specific: an unknown algorithm, a missing field, a box
135
+ addressed to a different key, and a failed tag check each say which one
136
+ happened rather than collapsing into "decryption failed".
137
+ """
138
+ algorithm = str((block or {}).get("algorithm") or "")
139
+ if algorithm != SEALED_BOX_ALGORITHM:
140
+ raise ValueError(
141
+ f"unsupported sealed-box algorithm: {algorithm or 'missing'}"
142
+ )
143
+ try:
144
+ ephemeral_raw = _unb64(block["ephemeral_public_key"])
145
+ salt = _unb64(block["salt"])
146
+ nonce = _unb64(block["nonce"])
147
+ ciphertext = _unb64(block["ciphertext"])
148
+ peer = X25519PublicKey.from_public_bytes(ephemeral_raw)
149
+ except Exception as exc: # noqa: BLE001 — a malformed envelope is one answer
150
+ raise ValueError(f"sealed bundle is missing encryption metadata: {exc}") from exc
151
+ recipient_raw = _raw_public(private_key.public_key())
152
+ addressed = str(block.get("recipient_public_key") or "")
153
+ if addressed and _unb64(addressed) != recipient_raw:
154
+ raise ValueError(
155
+ "this bundle was sealed to a different recipient key "
156
+ f"({block.get('recipient_fingerprint') or 'unknown fingerprint'})."
157
+ )
158
+ key = _derive(private_key.exchange(peer), salt, ephemeral_raw, recipient_raw)
159
+ try:
160
+ return bytes(AESGCM(key).decrypt(nonce, ciphertext, None))
161
+ except InvalidTag as exc:
162
+ raise ValueError(
163
+ "Sealed bundle decryption failed; the recipient key or the bundle is invalid."
164
+ ) from exc
165
+
166
+
167
+ class RecipientIdentity:
168
+ """This Brain's X25519 receiving keypair — loads or creates, file-backed.
169
+
170
+ Deliberately *not* the device's Ed25519 identity. That key signs, and a
171
+ signing key that also decrypts is a key whose compromise costs twice. The
172
+ private half lives in a 0600 file under the data directory and never leaves
173
+ the machine; :meth:`describe` reports exactly that rather than implying a
174
+ keychain this class does not use.
175
+ """
176
+
177
+ def __init__(self, data_dir: Any) -> None:
178
+ self._data_dir = Path(data_dir)
179
+ self._key_file = self._data_dir / RECIPIENT_KEY_FILENAME
180
+ self._private = self._load_or_create()
181
+
182
+ def _load_or_create(self) -> X25519PrivateKey:
183
+ if self._key_file.exists():
184
+ return X25519PrivateKey.from_private_bytes(
185
+ _unb64(self._key_file.read_text(encoding="utf-8").strip())
186
+ )
187
+ key = X25519PrivateKey.generate()
188
+ raw = key.private_bytes(
189
+ serialization.Encoding.Raw,
190
+ serialization.PrivateFormat.Raw,
191
+ serialization.NoEncryption(),
192
+ )
193
+ self._data_dir.mkdir(parents=True, exist_ok=True)
194
+ self._key_file.write_text(_b64(raw), encoding="utf-8")
195
+ os.chmod(self._key_file, 0o600)
196
+ return key
197
+
198
+ @property
199
+ def public_key_b64(self) -> str:
200
+ return _b64(_raw_public(self._private.public_key()))
201
+
202
+ @property
203
+ def fingerprint(self) -> str:
204
+ return public_key_fingerprint(self.public_key_b64)
205
+
206
+ def describe(self) -> Dict[str, Any]:
207
+ return {
208
+ "public_key": self.public_key_b64,
209
+ "fingerprint": self.fingerprint,
210
+ "algorithm": SEALED_BOX_ALGORITHM,
211
+ "storage": "file",
212
+ "path": str(self._key_file),
213
+ }
214
+
215
+ def seal_to_self(self, plaintext: bytes) -> Dict[str, Any]:
216
+ """Seal to this Brain's own key — the honest way to test a round trip."""
217
+ return seal(plaintext, recipient_public_key=self.public_key_b64)
218
+
219
+ def unseal(self, block: Dict[str, Any]) -> bytes:
220
+ return unseal(block, self._private)
221
+
222
+
223
+ def load_recipient_identity(data_dir: Any) -> Optional[RecipientIdentity]:
224
+ """Best-effort recipient key, or ``None`` when the directory is unusable.
225
+
226
+ Sharing is opt-in and a missing key is a *state*, not a crash: the status
227
+ surface reports "no receiving key" rather than failing a status read.
228
+ """
229
+ try:
230
+ return RecipientIdentity(data_dir)
231
+ except (OSError, ValueError): # noqa: BLE001 — unwritable dir / corrupt key file
232
+ return None
233
+
234
+
235
+ __all__ = [
236
+ "HKDF_INFO_PREFIX",
237
+ "RECIPIENT_KEY_FILENAME",
238
+ "SEALED_BOX_ALGORITHM",
239
+ "RecipientIdentity",
240
+ "load_recipient_identity",
241
+ "public_key_fingerprint",
242
+ "seal",
243
+ "unseal",
244
+ ]
@@ -35,6 +35,7 @@ import os
35
35
  from datetime import datetime, timezone
36
36
  from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple
37
37
 
38
+ from .gates import FeatureGate
38
39
  from .graph.proactive import ProactiveBrain
39
40
  from .graph.schema import KGStoreV2
40
41
  from .quality import content_signature
@@ -60,6 +61,19 @@ CONTRADICTION_RESOLUTIONS = ("keep_old", "replace", "keep_both_temporal")
60
61
  SYNTHESIS_THRESHOLD_ENV = "LATTICEAI_SYNTHESIS_THRESHOLD"
61
62
  DEFAULT_SYNTHESIS_THRESHOLD = 25
62
63
 
64
+ #: Whether the Brain is allowed to start a pass *by itself*. Default on — this
65
+ #: is the behaviour 11.1.0 shipped — and it governs only the automatic path
66
+ #: (:meth:`BrainSynthesizer.run_if_due`). An explicit run a person asked for is
67
+ #: never gated: turning "tidy up on its own" off means the Brain stops deciding
68
+ #: when, not that the button stops working.
69
+ SYNTHESIS_ENV = "LATTICEAI_SYNTHESIS"
70
+ SYNTHESIS_GATE = FeatureGate(
71
+ SYNTHESIS_ENV,
72
+ default=True,
73
+ name="synthesis",
74
+ detail="The Brain reviews accumulated material on its own and proposes tidy-ups.",
75
+ )
76
+
63
77
  _MIN_SHARED_TOKENS = 3
64
78
  _MIN_CLUSTER_MEMBERS = 3
65
79
  _EDGE_SIMILARITY = 0.35
@@ -516,7 +530,14 @@ class BrainSynthesizer:
516
530
  self, result: Any, *, workspace_id: Optional[str] = None,
517
531
  user_email: Optional[str] = None,
518
532
  ) -> Optional[Dict[str, Any]]:
519
- """Run synthesis only when this ingest pushed the counter over."""
533
+ """Run synthesis only when this ingest pushed the counter over.
534
+
535
+ The gate is checked *before* the counter moves, so "off" means off: a
536
+ long import while the switch is down does not bank a pass that fires the
537
+ instant someone turns it back on.
538
+ """
539
+ if not SYNTHESIS_GATE.enabled():
540
+ return None
520
541
  if not self.observe_ingest(result):
521
542
  return None
522
543
  return self.run(workspace_id=workspace_id, user_email=user_email)
@@ -792,6 +813,8 @@ __all__ = [
792
813
  "ContradictionResolutionError",
793
814
  "EDGE_KIND",
794
815
  "ProposalDesk",
816
+ "SYNTHESIS_ENV",
817
+ "SYNTHESIS_GATE",
795
818
  "SYNTHESIS_REVIEW_SOURCE",
796
819
  "SYNTHESIS_THRESHOLD_ENV",
797
820
  "SynthesisTrigger",
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "11.1.0"
3
+ __version__ = "11.2.0"
@@ -85,6 +85,10 @@ def create_brain_intelligence_router(
85
85
  Fixed contract consumed by the frontend:
86
86
  ``{"status": "ready"|"pending"|"unavailable", "pending_items": int,
87
87
  "total_items": int, "detail": str}``.
88
+
89
+ Additive since v11.2.0: ``breakdown`` splits the backlog into
90
+ ``embedded``/``missing``/``stale``/``queued`` when the store can. The
91
+ four keys above are unchanged, so existing readers see no difference.
88
92
  """
89
93
  user = require_user(request)
90
94
  scope = gate_read(request)
@@ -111,6 +111,16 @@ def create_chat_router(context: AppContext) -> APIRouter:
111
111
  get_history_user=context.require("get_history_user"),
112
112
  )
113
113
 
114
+ def review_sink() -> object:
115
+ """The Review Center queue, resolved per request (may be absent).
116
+
117
+ Cloud-derived knowledge is staged here rather than written, so the
118
+ hybrid turn needs the live queue — which is wired after this router's
119
+ context is built, hence a provider rather than a captured value.
120
+ """
121
+ provider = context.review_queue
122
+ return None if provider is None else provider()
123
+
114
124
  def notify_chat_message(role: str, text: str, source: Optional[str]) -> None:
115
125
  """Mirror persisted web exchanges to an injected bridge, never echoing it."""
116
126
  if context.on_chat_message is None or source == "telegram":
@@ -445,6 +455,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
445
455
  chat_service=chat_service,
446
456
  notify=notify_chat_message,
447
457
  model_id=selected_model_id,
458
+ review_queue=review_sink(),
448
459
  )
449
460
  if hybrid_response is not None:
450
461
  return hybrid_response