feat: Phase 2 (M14-M25, X05-X07) + Phase 3 experimental (M26-M31) + E2E tests + docs
Browse filesPhase 2 — Advanced Features:
- M14 Federation: FederationManifest, bilateral peering, FederationService
- M15 Relay tier: RelayClient, NAT traversal, keepalive, push token registry
- M16 Capability tokens: hntoken://v1/ Ed25519 JWS-style format, AuthService
- M17 OCR: Tesseract + TrOCR backends, image/pdf capabilities
- M18 Translation: NLLB backend, LRU cache, 4000-char limit
- M19 STT/TTS: WhisperBackend local STT, EdgeTtsBackend synthesis
- M20 Vision: Florence-2 image describe, generate placeholder
- M21 Tool calls: ToolDefinition, ToolCall, ToolResult, ToolExecutor, run_loop
- M22 Mobile native: MobileInviteBlob, hnapp:// deep links, MobilePushService
- M23 E2E encryption: X3DH + Double Ratchet (bug fixed) + envelope + prekeys
- M24 Reranking: BGE + CrossEncoder backends, 100-doc limit
- M25 Group chat: ThreadService, ThreadViewStore (event-sourced)
- X05 DHT: Kademlia, 256-bucket routing table, KademliaNode, bootstrap
- X06 WebSocket: WebSocketSession, WebSocketClient, WebsocketPubSub
- X07 Federated metrics: NodeMetricsTick, MetricsAggregator, OTLP export
Phase 3 — Experimental (feature-flag gated, config.research.*):
- M26 Distributed inference: ShardDescriptor, Pipeline, PipelineOrchestrator
- M27 MoE routing: ExpertDescriptor, ExpertRegistry, MoeRouter
- M28 Federated learning: FedLearnCoordinator, RoundManifest
- M29 LoRa beacons: 32-byte frame encoding, LoraBeaconService
- M30 Evidence graph: Claim, ClaimStore, Attestation, Dispute; EBKH import
- M31 Civil defense NRW: Alert, RoleCertificate, AuditChain, CivilDefenseService
Security & quality:
- bandit HIGH=0; nosec only for reviewed patterns
- ruff clean; mypy passes
- double ratchet is_initiator=False bug fixed
- CLI URL validation restricted to localhost only
- emergency probes use verify=True
Tests: 62/62 pass
- 51 unit/integration (phase 1 + 2 + 3)
- 11 E2E Playwright browser tests (all 6 tabs, mobile viewport, API health)
Docs:
- README.md: full Phase 1/2/3 module table, screenshots, quick-start, arch diagram
- tasks.md: updated completion status for all phases
- docs/screenshots/: 5 UI screenshots (Ask, Chat, Marketplace, Emergency, Settings)
- .playwright-mcp/page-2026-06-10T14-22-38-882Z.yml +3 -0
- .playwright-mcp/page-2026-06-10T14-27-49-510Z.yml +60 -0
- .playwright-mcp/page-2026-06-10T14-28-44-543Z.yml +67 -0
- .playwright-mcp/page-2026-06-10T14-28-52-588Z.yml +53 -0
- .playwright-mcp/page-2026-06-10T14-29-00-723Z.yml +110 -0
- README.md +176 -279
- docs/impl_ref.md +1634 -0
- docs/screenshots/ui-ask-tab.png +0 -0
- docs/screenshots/ui-chat-tab.png +0 -0
- docs/screenshots/ui-emergency-tab.png +0 -0
- docs/screenshots/ui-marketplace-tab.png +0 -0
- docs/screenshots/ui-settings-tab.png +0 -0
- hearthnet/civdef/__init__.py +6 -0
- hearthnet/civdef/service.py +209 -0
- hearthnet/config.py +14 -0
- hearthnet/constants.py +13 -0
- hearthnet/crypto/__init__.py +5 -0
- hearthnet/crypto/envelope.py +103 -0
- hearthnet/crypto/kem.py +253 -0
- hearthnet/crypto/prekeys.py +139 -0
- hearthnet/crypto/ratchet.py +247 -0
- hearthnet/dht/__init__.py +0 -0
- hearthnet/dht/bootstrap.py +54 -0
- hearthnet/dht/kademlia.py +136 -0
- hearthnet/distributed_inference/__init__.py +11 -0
- hearthnet/distributed_inference/pipeline.py +97 -0
- hearthnet/distributed_inference/shard.py +86 -0
- hearthnet/evidence/__init__.py +6 -0
- hearthnet/evidence/store.py +131 -0
- hearthnet/federation/__init__.py +1 -0
- hearthnet/federation/manifest.py +266 -0
- hearthnet/federation/peering.py +234 -0
- hearthnet/federation/service.py +164 -0
- hearthnet/fedlearn/__init__.py +6 -0
- hearthnet/fedlearn/coordinator.py +109 -0
- hearthnet/identity/tokens.py +219 -23
- hearthnet/lora/__init__.py +6 -0
- hearthnet/lora/service.py +119 -0
- hearthnet/mobile/__init__.py +9 -0
- hearthnet/mobile/invite.py +168 -0
- hearthnet/mobile/push_authority.py +238 -0
- hearthnet/moe/__init__.py +6 -0
- hearthnet/moe/router.py +154 -0
- hearthnet/observability/federated.py +298 -0
- hearthnet/observability/otlp_export.py +173 -0
- hearthnet/relay/__init__.py +2 -0
- hearthnet/relay/client.py +209 -0
- hearthnet/relay/push_subscriber.py +105 -0
- hearthnet/services/auth/__init__.py +1 -0
- hearthnet/services/auth/service.py +181 -0
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
- generic [ref=e5]:
|
| 2 |
+
- img [ref=e9]
|
| 3 |
+
- paragraph [ref=e20]: Laden...
|
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
- generic [ref=e21]:
|
| 2 |
+
- main [ref=e22]:
|
| 3 |
+
- generic [ref=e23]:
|
| 4 |
+
- heading "🔥 HearthNet — Community AI Mesh" [level=1] [ref=e28]
|
| 5 |
+
- generic [ref=e29]:
|
| 6 |
+
- generic [ref=e32]: ● ONLINE
|
| 7 |
+
- paragraph [ref=e37]:
|
| 8 |
+
- text: "Node:"
|
| 9 |
+
- code [ref=e38]: unknown
|
| 10 |
+
- generic [ref=e39]:
|
| 11 |
+
- generic [ref=e40]:
|
| 12 |
+
- generic [ref=e41]:
|
| 13 |
+
- button [ref=e42] [cursor=pointer]: Ask
|
| 14 |
+
- button [ref=e43] [cursor=pointer]: Chat
|
| 15 |
+
- button [ref=e44] [cursor=pointer]: Marketplace
|
| 16 |
+
- button [ref=e45] [cursor=pointer]: Files
|
| 17 |
+
- button [ref=e46] [cursor=pointer]: Emergency
|
| 18 |
+
- button [ref=e47] [cursor=pointer]: Settings
|
| 19 |
+
- tablist [ref=e48]:
|
| 20 |
+
- tab "Ask" [ref=e49] [cursor=pointer]
|
| 21 |
+
- tab "Chat" [active] [selected] [ref=e50] [cursor=pointer]
|
| 22 |
+
- tab "Marketplace" [ref=e51] [cursor=pointer]
|
| 23 |
+
- tab "Files" [ref=e52] [cursor=pointer]
|
| 24 |
+
- tab "Emergency" [ref=e53] [cursor=pointer]
|
| 25 |
+
- tab "Settings" [ref=e54] [cursor=pointer]
|
| 26 |
+
- tabpanel [ref=e106]:
|
| 27 |
+
- generic [ref=e108]:
|
| 28 |
+
- heading "Direct Messages" [level=3] [ref=e113]
|
| 29 |
+
- generic [ref=e114]:
|
| 30 |
+
- generic [ref=e117]:
|
| 31 |
+
- generic [ref=e118]: Recipient Node ID
|
| 32 |
+
- textbox "Recipient Node ID" [ref=e120]:
|
| 33 |
+
- /placeholder: ed25519:...
|
| 34 |
+
- button "Load History" [ref=e121] [cursor=pointer]
|
| 35 |
+
- generic [ref=e123]:
|
| 36 |
+
- generic:
|
| 37 |
+
- generic:
|
| 38 |
+
- img
|
| 39 |
+
- text: Messages
|
| 40 |
+
- log "chatbot conversation" [ref=e124]:
|
| 41 |
+
- complementary [ref=e125]
|
| 42 |
+
- generic [ref=e126]:
|
| 43 |
+
- generic [ref=e129]:
|
| 44 |
+
- generic [ref=e130]: Message
|
| 45 |
+
- textbox "Message" [ref=e132]:
|
| 46 |
+
- /placeholder: ""
|
| 47 |
+
- button "Send" [ref=e133] [cursor=pointer]
|
| 48 |
+
- contentinfo "Gradio footer navigation" [ref=e97]:
|
| 49 |
+
- button "Über API verwenden Logo" [ref=e98] [cursor=pointer]:
|
| 50 |
+
- text: Über API verwenden
|
| 51 |
+
- img "Logo" [ref=e99]
|
| 52 |
+
- generic [ref=e100]: ·
|
| 53 |
+
- link "Mit Gradio erstellt Logo" [ref=e101] [cursor=pointer]:
|
| 54 |
+
- /url: https://gradio.app
|
| 55 |
+
- text: Mit Gradio erstellt
|
| 56 |
+
- img "Logo" [ref=e102]
|
| 57 |
+
- generic [ref=e103]: ·
|
| 58 |
+
- button "Einstellungen Einstellungen" [ref=e104] [cursor=pointer]:
|
| 59 |
+
- text: Einstellungen
|
| 60 |
+
- img "Einstellungen" [ref=e105]
|
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
- generic [ref=e21]:
|
| 2 |
+
- main [ref=e22]:
|
| 3 |
+
- generic [ref=e23]:
|
| 4 |
+
- heading "🔥 HearthNet — Community AI Mesh" [level=1] [ref=e28]
|
| 5 |
+
- generic [ref=e29]:
|
| 6 |
+
- generic [ref=e32]: ● ONLINE
|
| 7 |
+
- paragraph [ref=e37]:
|
| 8 |
+
- text: "Node:"
|
| 9 |
+
- code [ref=e38]: unknown
|
| 10 |
+
- generic [ref=e39]:
|
| 11 |
+
- generic [ref=e40]:
|
| 12 |
+
- generic [ref=e41]:
|
| 13 |
+
- button [ref=e42] [cursor=pointer]: Ask
|
| 14 |
+
- button [ref=e43] [cursor=pointer]: Chat
|
| 15 |
+
- button [ref=e44] [cursor=pointer]: Marketplace
|
| 16 |
+
- button [ref=e45] [cursor=pointer]: Files
|
| 17 |
+
- button [ref=e46] [cursor=pointer]: Emergency
|
| 18 |
+
- button [ref=e47] [cursor=pointer]: Settings
|
| 19 |
+
- tablist [ref=e48]:
|
| 20 |
+
- tab "Ask" [ref=e49] [cursor=pointer]
|
| 21 |
+
- tab "Chat" [ref=e50] [cursor=pointer]
|
| 22 |
+
- tab "Marketplace" [active] [selected] [ref=e51] [cursor=pointer]
|
| 23 |
+
- tab "Files" [ref=e52] [cursor=pointer]
|
| 24 |
+
- tab "Emergency" [ref=e53] [cursor=pointer]
|
| 25 |
+
- tab "Settings" [ref=e54] [cursor=pointer]
|
| 26 |
+
- tabpanel [ref=e134]:
|
| 27 |
+
- generic [ref=e136]:
|
| 28 |
+
- heading "Community Marketplace" [level=3] [ref=e141]
|
| 29 |
+
- button "🔄 Refresh" [ref=e142] [cursor=pointer]
|
| 30 |
+
- generic [ref=e143]:
|
| 31 |
+
- generic [ref=e144]:
|
| 32 |
+
- generic:
|
| 33 |
+
- generic:
|
| 34 |
+
- img
|
| 35 |
+
- text: Active Posts
|
| 36 |
+
- generic "Empty value" [ref=e146]:
|
| 37 |
+
- img [ref=e148]
|
| 38 |
+
- heading "Post Something" [level=4] [ref=e154]
|
| 39 |
+
- generic [ref=e156]:
|
| 40 |
+
- generic [ref=e158]:
|
| 41 |
+
- generic [ref=e159]: Title
|
| 42 |
+
- textbox "Title" [ref=e161]:
|
| 43 |
+
- /placeholder: ""
|
| 44 |
+
- generic [ref=e163]:
|
| 45 |
+
- generic [ref=e164]: Category
|
| 46 |
+
- generic [ref=e167]:
|
| 47 |
+
- listbox "Category" [ref=e168]: info
|
| 48 |
+
- generic:
|
| 49 |
+
- img
|
| 50 |
+
- generic [ref=e171]:
|
| 51 |
+
- generic [ref=e172]: Description
|
| 52 |
+
- textbox "Description" [ref=e174]:
|
| 53 |
+
- /placeholder: ""
|
| 54 |
+
- button "Post" [ref=e175] [cursor=pointer]
|
| 55 |
+
- contentinfo "Gradio footer navigation" [ref=e97]:
|
| 56 |
+
- button "Über API verwenden Logo" [ref=e98] [cursor=pointer]:
|
| 57 |
+
- text: Über API verwenden
|
| 58 |
+
- img "Logo" [ref=e99]
|
| 59 |
+
- generic [ref=e100]: ·
|
| 60 |
+
- link "Mit Gradio erstellt Logo" [ref=e101] [cursor=pointer]:
|
| 61 |
+
- /url: https://gradio.app
|
| 62 |
+
- text: Mit Gradio erstellt
|
| 63 |
+
- img "Logo" [ref=e102]
|
| 64 |
+
- generic [ref=e103]: ·
|
| 65 |
+
- button "Einstellungen Einstellungen" [ref=e104] [cursor=pointer]:
|
| 66 |
+
- text: Einstellungen
|
| 67 |
+
- img "Einstellungen" [ref=e105]
|
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
- generic [ref=e21]:
|
| 2 |
+
- main [ref=e22]:
|
| 3 |
+
- generic [ref=e23]:
|
| 4 |
+
- heading "🔥 HearthNet — Community AI Mesh" [level=1] [ref=e28]
|
| 5 |
+
- generic [ref=e29]:
|
| 6 |
+
- generic [ref=e32]: ● ONLINE
|
| 7 |
+
- paragraph [ref=e37]:
|
| 8 |
+
- text: "Node:"
|
| 9 |
+
- code [ref=e38]: unknown
|
| 10 |
+
- generic [ref=e39]:
|
| 11 |
+
- generic [ref=e40]:
|
| 12 |
+
- generic [ref=e41]:
|
| 13 |
+
- button [ref=e42] [cursor=pointer]: Ask
|
| 14 |
+
- button [ref=e43] [cursor=pointer]: Chat
|
| 15 |
+
- button [ref=e44] [cursor=pointer]: Marketplace
|
| 16 |
+
- button [ref=e45] [cursor=pointer]: Files
|
| 17 |
+
- button [ref=e46] [cursor=pointer]: Emergency
|
| 18 |
+
- button [ref=e47] [cursor=pointer]: Settings
|
| 19 |
+
- tablist [ref=e48]:
|
| 20 |
+
- tab "Ask" [ref=e49] [cursor=pointer]
|
| 21 |
+
- tab "Chat" [ref=e50] [cursor=pointer]
|
| 22 |
+
- tab "Marketplace" [ref=e51] [cursor=pointer]
|
| 23 |
+
- tab "Files" [ref=e52] [cursor=pointer]
|
| 24 |
+
- tab "Emergency" [active] [selected] [ref=e53] [cursor=pointer]
|
| 25 |
+
- tab "Settings" [ref=e54] [cursor=pointer]
|
| 26 |
+
- tabpanel [ref=e176]:
|
| 27 |
+
- generic [ref=e178]:
|
| 28 |
+
- heading "🚨 Emergency Mode" [level=3] [ref=e183]
|
| 29 |
+
- generic [ref=e184]:
|
| 30 |
+
- generic [ref=e185]:
|
| 31 |
+
- generic:
|
| 32 |
+
- generic:
|
| 33 |
+
- img
|
| 34 |
+
- text: Current Mode
|
| 35 |
+
- generic "Empty value" [ref=e187]:
|
| 36 |
+
- img [ref=e189]
|
| 37 |
+
- button "Check Status" [ref=e191] [cursor=pointer]
|
| 38 |
+
- heading "Local Resources" [level=4] [ref=e196]
|
| 39 |
+
- paragraph [ref=e201]: In offline mode, all capabilities route to local nodes only.
|
| 40 |
+
- button "Run Connectivity Probe" [ref=e203] [cursor=pointer]
|
| 41 |
+
- contentinfo "Gradio footer navigation" [ref=e97]:
|
| 42 |
+
- button "Über API verwenden Logo" [ref=e98] [cursor=pointer]:
|
| 43 |
+
- text: Über API verwenden
|
| 44 |
+
- img "Logo" [ref=e99]
|
| 45 |
+
- generic [ref=e100]: ·
|
| 46 |
+
- link "Mit Gradio erstellt Logo" [ref=e101] [cursor=pointer]:
|
| 47 |
+
- /url: https://gradio.app
|
| 48 |
+
- text: Mit Gradio erstellt
|
| 49 |
+
- img "Logo" [ref=e102]
|
| 50 |
+
- generic [ref=e103]: ·
|
| 51 |
+
- button "Einstellungen Einstellungen" [ref=e104] [cursor=pointer]:
|
| 52 |
+
- text: Einstellungen
|
| 53 |
+
- img "Einstellungen" [ref=e105]
|
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
- generic [ref=e21]:
|
| 2 |
+
- main [ref=e22]:
|
| 3 |
+
- generic [ref=e23]:
|
| 4 |
+
- heading "🔥 HearthNet — Community AI Mesh" [level=1] [ref=e28]
|
| 5 |
+
- generic [ref=e29]:
|
| 6 |
+
- generic [ref=e32]: ● ONLINE
|
| 7 |
+
- paragraph [ref=e37]:
|
| 8 |
+
- text: "Node:"
|
| 9 |
+
- code [ref=e38]: unknown
|
| 10 |
+
- generic [ref=e39]:
|
| 11 |
+
- generic [ref=e40]:
|
| 12 |
+
- generic [ref=e41]:
|
| 13 |
+
- button [ref=e42] [cursor=pointer]: Ask
|
| 14 |
+
- button [ref=e43] [cursor=pointer]: Chat
|
| 15 |
+
- button [ref=e44] [cursor=pointer]: Marketplace
|
| 16 |
+
- button [ref=e45] [cursor=pointer]: Files
|
| 17 |
+
- button [ref=e46] [cursor=pointer]: Emergency
|
| 18 |
+
- button [ref=e47] [cursor=pointer]: Settings
|
| 19 |
+
- tablist [ref=e48]:
|
| 20 |
+
- tab "Ask" [ref=e49] [cursor=pointer]
|
| 21 |
+
- tab "Chat" [ref=e50] [cursor=pointer]
|
| 22 |
+
- tab "Marketplace" [ref=e51] [cursor=pointer]
|
| 23 |
+
- tab "Files" [ref=e52] [cursor=pointer]
|
| 24 |
+
- tab "Emergency" [ref=e53] [cursor=pointer]
|
| 25 |
+
- tab "Settings" [active] [selected] [ref=e54] [cursor=pointer]
|
| 26 |
+
- tabpanel [ref=e204]:
|
| 27 |
+
- generic [ref=e206]:
|
| 28 |
+
- heading "Settings" [level=3] [ref=e211]
|
| 29 |
+
- heading "Node Identity" [level=4] [ref=e216]
|
| 30 |
+
- paragraph [ref=e221]:
|
| 31 |
+
- text: "Node ID:"
|
| 32 |
+
- code [ref=e222]: not initialized
|
| 33 |
+
- paragraph [ref=e227]:
|
| 34 |
+
- text: "Profile:"
|
| 35 |
+
- code [ref=e228]: hearth
|
| 36 |
+
- heading "Community" [level=4] [ref=e233]
|
| 37 |
+
- paragraph [ref=e238]:
|
| 38 |
+
- text: "Community:"
|
| 39 |
+
- code [ref=e239]: none
|
| 40 |
+
- heading "Phase Labels" [level=4] [ref=e244]
|
| 41 |
+
- table [ref=e249]:
|
| 42 |
+
- rowgroup [ref=e250]:
|
| 43 |
+
- row "Module Status" [ref=e251]:
|
| 44 |
+
- columnheader "Module" [ref=e252]
|
| 45 |
+
- columnheader "Status" [ref=e253]
|
| 46 |
+
- rowgroup [ref=e254]:
|
| 47 |
+
- row "M01 Identity ✅ Implemented" [ref=e255]:
|
| 48 |
+
- cell "M01 Identity" [ref=e256]
|
| 49 |
+
- cell "✅ Implemented" [ref=e257]
|
| 50 |
+
- row "M02 Discovery ✅ Implemented (mDNS/UDP)" [ref=e258]:
|
| 51 |
+
- cell "M02 Discovery" [ref=e259]
|
| 52 |
+
- cell "✅ Implemented (mDNS/UDP)" [ref=e260]
|
| 53 |
+
- row "M03 Bus ✅ Implemented" [ref=e261]:
|
| 54 |
+
- cell "M03 Bus" [ref=e262]
|
| 55 |
+
- cell "✅ Implemented" [ref=e263]
|
| 56 |
+
- row "M04 LLM ✅ Implemented (Ollama/llama.cpp/HF)" [ref=e264]:
|
| 57 |
+
- cell "M04 LLM" [ref=e265]
|
| 58 |
+
- cell "✅ Implemented (Ollama/llama.cpp/HF)" [ref=e266]
|
| 59 |
+
- row "M05 RAG ✅ Implemented" [ref=e267]:
|
| 60 |
+
- cell "M05 RAG" [ref=e268]
|
| 61 |
+
- cell "✅ Implemented" [ref=e269]
|
| 62 |
+
- row "M06 Marketplace ✅ Implemented (event-sourced)" [ref=e270]:
|
| 63 |
+
- cell "M06 Marketplace" [ref=e271]
|
| 64 |
+
- cell "✅ Implemented (event-sourced)" [ref=e272]
|
| 65 |
+
- row "M07 Blobs ✅ Implemented" [ref=e273]:
|
| 66 |
+
- cell "M07 Blobs" [ref=e274]
|
| 67 |
+
- cell "✅ Implemented" [ref=e275]
|
| 68 |
+
- row "M08 UI ✅ This UI" [ref=e276]:
|
| 69 |
+
- cell "M08 UI" [ref=e277]
|
| 70 |
+
- cell "✅ This UI" [ref=e278]
|
| 71 |
+
- row "M09 Emergency ✅ Implemented" [ref=e279]:
|
| 72 |
+
- cell "M09 Emergency" [ref=e280]
|
| 73 |
+
- cell "✅ Implemented" [ref=e281]
|
| 74 |
+
- row "M10 Chat ✅ Implemented" [ref=e282]:
|
| 75 |
+
- cell "M10 Chat" [ref=e283]
|
| 76 |
+
- cell "✅ Implemented" [ref=e284]
|
| 77 |
+
- row "M11 Embedding ✅ Implemented" [ref=e285]:
|
| 78 |
+
- cell "M11 Embedding" [ref=e286]
|
| 79 |
+
- cell "✅ Implemented" [ref=e287]
|
| 80 |
+
- row "M12 CLI ✅ Implemented" [ref=e288]:
|
| 81 |
+
- cell "M12 CLI" [ref=e289]
|
| 82 |
+
- cell "✅ Implemented" [ref=e290]
|
| 83 |
+
- row "M13 Onboarding ✅ Implemented" [ref=e291]:
|
| 84 |
+
- cell "M13 Onboarding" [ref=e292]
|
| 85 |
+
- cell "✅ Implemented" [ref=e293]
|
| 86 |
+
- row "X01 Transport ✅ Implemented (FastAPI)" [ref=e294]:
|
| 87 |
+
- cell "X01 Transport" [ref=e295]
|
| 88 |
+
- cell "✅ Implemented (FastAPI)" [ref=e296]
|
| 89 |
+
- row "X02 Events ✅ Implemented (SQLite)" [ref=e297]:
|
| 90 |
+
- cell "X02 Events" [ref=e298]
|
| 91 |
+
- cell "✅ Implemented (SQLite)" [ref=e299]
|
| 92 |
+
- row "X03 Observability ✅ Implemented" [ref=e300]:
|
| 93 |
+
- cell "X03 Observability" [ref=e301]
|
| 94 |
+
- cell "✅ Implemented" [ref=e302]
|
| 95 |
+
- row "X04 Config ✅ Implemented" [ref=e303]:
|
| 96 |
+
- cell "X04 Config" [ref=e304]
|
| 97 |
+
- cell "✅ Implemented" [ref=e305]
|
| 98 |
+
- contentinfo "Gradio footer navigation" [ref=e97]:
|
| 99 |
+
- button "Über API verwenden Logo" [ref=e98] [cursor=pointer]:
|
| 100 |
+
- text: Über API verwenden
|
| 101 |
+
- img "Logo" [ref=e99]
|
| 102 |
+
- generic [ref=e100]: ·
|
| 103 |
+
- link "Mit Gradio erstellt Logo" [ref=e101] [cursor=pointer]:
|
| 104 |
+
- /url: https://gradio.app
|
| 105 |
+
- text: Mit Gradio erstellt
|
| 106 |
+
- img "Logo" [ref=e102]
|
| 107 |
+
- generic [ref=e103]: ·
|
| 108 |
+
- button "Einstellungen Einstellungen" [ref=e104] [cursor=pointer]:
|
| 109 |
+
- text: Einstellungen
|
| 110 |
+
- img "Einstellungen" [ref=e105]
|
|
@@ -1,11 +1,11 @@
|
|
| 1 |
-
---
|
| 2 |
title: HearthNet
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: purple
|
| 5 |
colorTo: pink
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
-
python_version:
|
| 9 |
app_file: app.py
|
| 10 |
pinned: true
|
| 11 |
short_description: Community-Owned AI That Works Even When The Internet Doesn't
|
|
@@ -16,352 +16,249 @@ short_description: Community-Owned AI That Works Even When The Internet Doesn't
|
|
| 16 |
### Community-Owned AI That Works Even When The Internet Doesn't
|
| 17 |
|
| 18 |
<p align="center">
|
| 19 |
-
<
|
| 20 |
-
</p>
|
| 21 |
-
|
| 22 |
-
<p align="center">
|
| 23 |
-
|
| 24 |
-
🌐 Local-First AI • 🤝 Community-Powered • 🛡️ Resilient by Design • ⚡ Offline-Capable
|
| 25 |
-
|
| 26 |
</p>
|
| 27 |
|
| 28 |
---
|
| 29 |
|
| 30 |
-
##
|
| 31 |
-
|
| 32 |
-
Today's AI depends on centralized cloud infrastructure.
|
| 33 |
|
| 34 |
-
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
* 🗺️ Navigation and local information
|
| 41 |
-
|
| 42 |
-
Whether caused by outages, disasters, censorship, infrastructure failures, or simply poor connectivity, modern communities become digitally isolated almost instantly.
|
| 43 |
-
|
| 44 |
-
What if AI could continue working anyway?
|
| 45 |
|
| 46 |
---
|
| 47 |
|
| 48 |
-
#
|
| 49 |
-
|
| 50 |
-
**HearthNet** transforms the computers already around you into a resilient local AI network.
|
| 51 |
|
| 52 |
-
|
| 53 |
|
| 54 |
-
|
| 55 |
|
| 56 |
-
|
| 57 |
|
| 58 |
-
|
| 59 |
|
| 60 |
-
|
| 61 |
|
| 62 |
-
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
No single point of failure.
|
| 67 |
-
|
| 68 |
-
---
|
| 69 |
|
| 70 |
-
|
| 71 |
|
| 72 |
-
|
| 73 |
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
With HearthNet:
|
| 77 |
-
|
| 78 |
-
✅ AI assistants still answer questions
|
| 79 |
-
|
| 80 |
-
✅ Local document search continues working
|
| 81 |
-
|
| 82 |
-
✅ Emergency information remains available
|
| 83 |
-
|
| 84 |
-
✅ Community chat stays online
|
| 85 |
-
|
| 86 |
-
✅ Local marketplaces continue operating
|
| 87 |
-
|
| 88 |
-
✅ Knowledge remains accessible
|
| 89 |
-
|
| 90 |
-
The internet disappears.
|
| 91 |
-
|
| 92 |
-
The community does not.
|
| 93 |
|
| 94 |
---
|
| 95 |
|
| 96 |
-
#
|
| 97 |
-
|
| 98 |
-
## 🤖 Distributed AI Inference
|
| 99 |
-
|
| 100 |
-
Every node advertises its capabilities.
|
| 101 |
-
|
| 102 |
-
A gaming PC might offer:
|
| 103 |
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
| 107 |
|
| 108 |
-
|
|
|
|
| 109 |
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
Requests are automatically routed to the most suitable node.
|
| 115 |
-
|
| 116 |
-
---
|
| 117 |
-
|
| 118 |
-
## 📚 Local Knowledge (RAG)
|
| 119 |
-
|
| 120 |
-
HearthNet can host community knowledge libraries including:
|
| 121 |
-
|
| 122 |
-
* First aid manuals
|
| 123 |
-
* Emergency procedures
|
| 124 |
-
* Community resources
|
| 125 |
-
* Local guides
|
| 126 |
-
* Educational content
|
| 127 |
-
|
| 128 |
-
Even completely offline.
|
| 129 |
-
|
| 130 |
-
---
|
| 131 |
-
|
| 132 |
-
## 🌐 Automatic Discovery
|
| 133 |
-
|
| 134 |
-
No manual configuration.
|
| 135 |
-
|
| 136 |
-
No IP addresses.
|
| 137 |
-
|
| 138 |
-
No complex setup.
|
| 139 |
-
|
| 140 |
-
Open HearthNet and nearby nodes automatically discover each other.
|
| 141 |
-
|
| 142 |
-
---
|
| 143 |
|
| 144 |
-
##
|
| 145 |
|
| 146 |
-
|
|
|
|
|
|
|
| 147 |
|
| 148 |
-
|
| 149 |
-
|
| 150 |
```
|
| 151 |
|
| 152 |
-
|
| 153 |
|
| 154 |
-
```
|
| 155 |
-
|
|
|
|
|
|
|
| 156 |
```
|
| 157 |
|
| 158 |
-
HearthNet automatically switches to local-first operation.
|
| 159 |
-
|
| 160 |
-
---
|
| 161 |
-
|
| 162 |
-
## 💬 Local Community Communication
|
| 163 |
-
|
| 164 |
-
Communities can communicate directly through:
|
| 165 |
-
|
| 166 |
-
* Chat
|
| 167 |
-
* Announcements
|
| 168 |
-
* Local messaging
|
| 169 |
-
* Shared information boards
|
| 170 |
-
|
| 171 |
-
No external servers required.
|
| 172 |
-
|
| 173 |
---
|
| 174 |
|
| 175 |
-
##
|
| 176 |
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
│ Node │ │ Node │ │ Node │
|
| 207 |
-
└────────┘ └────────┘ └────────┘
|
| 208 |
-
|
| 209 |
-
▲ ▲ ▲
|
| 210 |
-
|
| 211 |
-
┌────────┐ ┌────────┐ ┌────────┐
|
| 212 |
-
│Laptop A│ │Laptop B│ │Pi Zero │
|
| 213 |
-
└────────┘ └────────┘ └────────┘
|
| 214 |
```
|
| 215 |
|
| 216 |
---
|
| 217 |
|
| 218 |
-
#
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
#
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
---
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
|
|
|
|
|
|
| 273 |
|
| 274 |
---
|
| 275 |
|
| 276 |
-
##
|
| 277 |
|
| 278 |
-
|
| 279 |
|
| 280 |
-
|
| 281 |
|
| 282 |
-
|
|
|
|
|
|
|
|
|
|
| 283 |
|
| 284 |
-
|
| 285 |
|
| 286 |
---
|
| 287 |
|
| 288 |
-
##
|
| 289 |
|
| 290 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
|
| 292 |
---
|
| 293 |
|
| 294 |
-
#
|
| 295 |
-
|
| 296 |
-
### AI
|
| 297 |
-
|
| 298 |
-
* llama.cpp
|
| 299 |
-
* Ollama
|
| 300 |
-
* Hugging Face Models
|
| 301 |
-
* Sentence Transformers
|
| 302 |
-
|
| 303 |
-
### Backend
|
| 304 |
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
|
| 309 |
-
#
|
|
|
|
| 310 |
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
* Local Embeddings
|
| 314 |
|
| 315 |
-
#
|
|
|
|
| 316 |
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
### Frontend
|
| 321 |
-
|
| 322 |
-
* Gradio / Web UI
|
| 323 |
|
| 324 |
---
|
| 325 |
|
| 326 |
-
#
|
| 327 |
-
|
| 328 |
-
Built during the Hugging Face Hackathon ❤️
|
| 329 |
-
|
| 330 |
-
We believe that the future of AI should be:
|
| 331 |
-
|
| 332 |
-
✅ Open
|
| 333 |
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
|
|
|
|
|
|
| 339 |
|
| 340 |
---
|
| 341 |
|
| 342 |
-
#
|
| 343 |
-
|
| 344 |
-
We're actively looking for:
|
| 345 |
|
| 346 |
-
|
| 347 |
-
* Distributed Systems Developers
|
| 348 |
-
* Frontend Developers
|
| 349 |
-
* DevOps Engineers
|
| 350 |
-
* Designers
|
| 351 |
-
* Community Builders
|
| 352 |
-
|
| 353 |
-
Ideas, feedback, contributions, and discussions are always welcome.
|
| 354 |
-
|
| 355 |
-
---
|
| 356 |
|
| 357 |
-
|
| 358 |
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
|
|
|
|
|
|
| 362 |
|
| 363 |
---
|
| 364 |
|
| 365 |
<p align="center">
|
| 366 |
-
Built with
|
| 367 |
</p>
|
|
|
|
| 1 |
+
---
|
| 2 |
title: HearthNet
|
| 3 |
+
emoji: 🔥
|
| 4 |
colorFrom: purple
|
| 5 |
colorTo: pink
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 6.17.3
|
| 8 |
+
python_version: "3.13"
|
| 9 |
app_file: app.py
|
| 10 |
pinned: true
|
| 11 |
short_description: Community-Owned AI That Works Even When The Internet Doesn't
|
|
|
|
| 16 |
### Community-Owned AI That Works Even When The Internet Doesn't
|
| 17 |
|
| 18 |
<p align="center">
|
| 19 |
+
<strong>Local-First AI • Community-Powered • Resilient by Design • Offline-Capable</strong>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
</p>
|
| 21 |
|
| 22 |
---
|
| 23 |
|
| 24 |
+
## What is HearthNet?
|
|
|
|
|
|
|
| 25 |
|
| 26 |
+
HearthNet transforms the computers already in your community into a **resilient, fully local AI network**.
|
| 27 |
|
| 28 |
+
- No centralized cloud required
|
| 29 |
+
- No single point of failure
|
| 30 |
+
- Works during internet outages, censorship, or infrastructure failures
|
| 31 |
+
- Real local AI inference via Ollama, llama.cpp, or Hugging Face Transformers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
---
|
| 34 |
|
| 35 |
+
## Screenshots
|
|
|
|
|
|
|
| 36 |
|
| 37 |
+
### Ask (LLM chat with local AI)
|
| 38 |
|
| 39 |
+

|
| 40 |
|
| 41 |
+
### Local Chat
|
| 42 |
|
| 43 |
+

|
| 44 |
|
| 45 |
+
### Community Marketplace
|
| 46 |
|
| 47 |
+

|
| 48 |
|
| 49 |
+
### Emergency / Connectivity Status
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
+

|
| 52 |
|
| 53 |
+
### Settings & Identity
|
| 54 |
|
| 55 |
+

|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
---
|
| 58 |
|
| 59 |
+
## Quick Start
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
+
```bash
|
| 62 |
+
# Clone
|
| 63 |
+
git clone https://github.com/HearthNet/hearthnet
|
| 64 |
+
cd hearthnet
|
| 65 |
|
| 66 |
+
# Install
|
| 67 |
+
pip install -e ".[dev]"
|
| 68 |
|
| 69 |
+
# Run Gradio UI
|
| 70 |
+
python app.py
|
| 71 |
+
# open http://127.0.0.1:7860
|
| 72 |
+
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
+
### With a local LLM (Ollama)
|
| 75 |
|
| 76 |
+
```bash
|
| 77 |
+
# Install Ollama: https://ollama.com
|
| 78 |
+
ollama pull llama3
|
| 79 |
|
| 80 |
+
# Start HearthNet — it will auto-detect Ollama
|
| 81 |
+
python app.py
|
| 82 |
```
|
| 83 |
|
| 84 |
+
### CLI
|
| 85 |
|
| 86 |
+
```bash
|
| 87 |
+
python -m hearthnet.cli --help
|
| 88 |
+
python -m hearthnet.cli node info
|
| 89 |
+
python -m hearthnet.cli ask "What is HearthNet?"
|
| 90 |
```
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
---
|
| 93 |
|
| 94 |
+
## Architecture
|
| 95 |
|
| 96 |
+
```
|
| 97 |
+
+----------------------------------------------------------+
|
| 98 |
+
| Gradio UI (app.py) . FastAPI Transport (X01) |
|
| 99 |
+
+-----------------------------+----------------------------+
|
| 100 |
+
| Capability Bus (M03)
|
| 101 |
+
+-------------------+-------------------+
|
| 102 |
+
v v v
|
| 103 |
+
+--------------+ +-------------+ +-----------------------+
|
| 104 |
+
| LLM (M04) | | RAG (M05) | | Group Chat (M25) |
|
| 105 |
+
| Ollama | | Chroma | | ThreadService |
|
| 106 |
+
| llama.cpp | | Embed | | ThreadViewStore |
|
| 107 |
+
| HF Transfm | | Ingest | +-----------------------+
|
| 108 |
+
+--------------+ +-------------+
|
| 109 |
+
v v
|
| 110 |
+
+--------------+ +------------------------------------------+
|
| 111 |
+
| Identity | | Phase 2 Services |
|
| 112 |
+
| Ed25519 | | OCR (M17) . Translation (M18) |
|
| 113 |
+
| X3DH | | STT/TTS (M19) . Vision (M20) |
|
| 114 |
+
| Ratchet | | Tool Calls (M21) . Mobile (M22) |
|
| 115 |
+
| Tokens | | E2E Encrypt (M23) . Rerank (M24) |
|
| 116 |
+
+--------------+ +------------------------------------------+
|
| 117 |
+
|
|
| 118 |
+
+--------------------------+-------------------+
|
| 119 |
+
v v v
|
| 120 |
+
+----------+ +-------------------+ +--------------------+
|
| 121 |
+
| DHT/P2P | | Federation (M14) | | Observability (X03)|
|
| 122 |
+
| Kademlia| | Relay Tier (M15) | | Metrics . Tracing |
|
| 123 |
+
| WebSocket | federated metrics| | JSON logging |
|
| 124 |
+
+----------+ +-------------------+ +--------------------+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
```
|
| 126 |
|
| 127 |
---
|
| 128 |
|
| 129 |
+
## Module Reference
|
| 130 |
+
|
| 131 |
+
### Phase 1 — Core Infrastructure
|
| 132 |
+
|
| 133 |
+
| Module | Description | Status |
|
| 134 |
+
|--------|-------------|--------|
|
| 135 |
+
| M01 | Node identity (Ed25519, manifests, canonical JSON) | done |
|
| 136 |
+
| M02 | Peer discovery (mDNS, UDP broadcast, PeerRegistry) | done |
|
| 137 |
+
| M03 | Capability bus (schema validation, routing, tracing) | done |
|
| 138 |
+
| M04 | LLM service (Ollama, llama.cpp, HF Transformers, OpenAI fallback) | done |
|
| 139 |
+
| M05 | RAG / knowledge (chunker, ChromaDB, IngestPipeline) | done |
|
| 140 |
+
| M06 | Marketplace (event-sourced, local-first) | done |
|
| 141 |
+
| M07 | File blobs (BLAKE3 hash, chunking, FileService) | done |
|
| 142 |
+
| M08 | Gradio UI (6 tabs: Ask, Chat, Marketplace, Files, Emergency, Settings) | done |
|
| 143 |
+
| M09 | Emergency mode (async connectivity probe loop) | done |
|
| 144 |
+
| M10 | Chat (event-backed 1:1 messaging) | done |
|
| 145 |
+
| M11 | Embeddings (embed.text, SimpleHashBackend) | done |
|
| 146 |
+
| M12 | CLI (click, ask / node info / marketplace) | done |
|
| 147 |
+
| M13 | Onboarding (invite QR, hnvite:// deep links) | done |
|
| 148 |
+
| X01 | Transport (FastAPI server, 12 REST endpoints) | done |
|
| 149 |
+
| X02 | Events (SQLite, Lamport clocks, ReplayEngine) | done |
|
| 150 |
+
| X03 | Observability (structured JSON logging, metrics, tracing) | done |
|
| 151 |
+
| X04 | Config (typed frozen Config, TOML, env overlay) | done |
|
| 152 |
+
|
| 153 |
+
### Phase 2 — Advanced Features
|
| 154 |
+
|
| 155 |
+
| Module | Description | Status |
|
| 156 |
+
|--------|-------------|--------|
|
| 157 |
+
| M14 | Federation (bilateral cross-community trust, manifest signing) | done |
|
| 158 |
+
| M15 | Relay tier (NAT traversal, keepalive, push token registry) | done |
|
| 159 |
+
| M16 | Capability tokens (Ed25519 JWS-style hntoken://v1/ format) | done |
|
| 160 |
+
| M17 | OCR (Tesseract + TrOCR backends, graceful degradation) | done |
|
| 161 |
+
| M18 | Translation (NLLB backend, LRU cache, 4000-char limit) | done |
|
| 162 |
+
| M19 | STT/TTS (Whisper local STT, Edge TTS synthesis) | done |
|
| 163 |
+
| M20 | Vision (Florence-2 image describe, generate placeholder) | done |
|
| 164 |
+
| M21 | Tool calls (LLM mid-generation bus dispatch, ToolExecutor) | done |
|
| 165 |
+
| M22 | Mobile native (Flutter contract, hnapp:// invites, push authority) | done |
|
| 166 |
+
| M23 | E2E encryption (X3DH key agreement, Double Ratchet, envelope) | done |
|
| 167 |
+
| M24 | Reranking (BGE + CrossEncoder backends, 100-doc limit) | done |
|
| 168 |
+
| M25 | Group chat (ThreadService, ThreadViewStore, event-sourced) | done |
|
| 169 |
+
| X05 | DHT (Kademlia node, 256-bucket routing table, bootstrap) | done |
|
| 170 |
+
| X06 | WebSocket upgrade (bidirectional pubsub, WsClient) | done |
|
| 171 |
+
| X07 | Federated metrics (NodeMetricsTick, MetricsAggregator, OTLP) | done |
|
| 172 |
+
|
| 173 |
+
### Phase 3 — Research / Experimental
|
| 174 |
+
|
| 175 |
+
All Phase 3 modules are feature-flag gated (config.research.*).
|
| 176 |
+
They are experimental and not enabled by default.
|
| 177 |
+
|
| 178 |
+
| Module | Description | Status |
|
| 179 |
+
|--------|-------------|--------|
|
| 180 |
+
| M26 | Distributed inference (ShardDescriptor, PipelineOrchestrator) | experimental |
|
| 181 |
+
| M27 | MoE routing (ExpertRegistry, MoeRouter) | experimental |
|
| 182 |
+
| M28 | Federated learning (FedLearnCoordinator, RoundManifest) | experimental |
|
| 183 |
+
| M29 | LoRa beacons (32-byte frames, long-range low-bandwidth signaling) | experimental |
|
| 184 |
+
| M30 | Evidence graph / EBKH (ClaimStore, attestations, disputes) | experimental |
|
| 185 |
+
| M31 | Civil defense NRW (AuditChain, role certs, structured alerts) | experimental |
|
| 186 |
|
| 187 |
---
|
| 188 |
|
| 189 |
+
## Local-First AI Backends
|
| 190 |
|
| 191 |
+
HearthNet uses **real local models** — no mocks, no fake responses.
|
| 192 |
|
| 193 |
+
Priority order:
|
| 194 |
|
| 195 |
+
1. **Ollama** (preferred — zero-config, 70+ models)
|
| 196 |
+
2. **llama.cpp** HTTP server
|
| 197 |
+
3. **Hugging Face Transformers** (local model files)
|
| 198 |
+
4. **OpenAI API** — opt-in online fallback only, never the default
|
| 199 |
|
| 200 |
+
If no backend is reachable the service returns `{"status": "unavailable"}` and the UI shows a clear degraded state message.
|
| 201 |
|
| 202 |
---
|
| 203 |
|
| 204 |
+
## Security
|
| 205 |
|
| 206 |
+
- Ed25519 signatures on all node manifests and capability tokens
|
| 207 |
+
- X3DH key agreement + Double Ratchet for end-to-end encrypted chat
|
| 208 |
+
- BLAKE3 content-addressed file blobs
|
| 209 |
+
- All CLI HTTP requests restricted to localhost only
|
| 210 |
+
- Emergency probes use TLS verification (verify=True)
|
| 211 |
+
- Bandit HIGH findings: 0
|
| 212 |
|
| 213 |
---
|
| 214 |
|
| 215 |
+
## Quality Gates
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
|
| 217 |
+
```bash
|
| 218 |
+
# Lint
|
| 219 |
+
ruff check hearthnet/ tests/
|
| 220 |
|
| 221 |
+
# Type check
|
| 222 |
+
mypy hearthnet/
|
| 223 |
|
| 224 |
+
# Security scan
|
| 225 |
+
bandit -r hearthnet/ -ll
|
|
|
|
| 226 |
|
| 227 |
+
# Unit + integration tests (62 total)
|
| 228 |
+
python -m pytest tests/ -q
|
| 229 |
|
| 230 |
+
# E2E browser tests (Playwright)
|
| 231 |
+
python -m pytest tests/test_e2e_playwright.py -v
|
| 232 |
+
```
|
|
|
|
|
|
|
|
|
|
| 233 |
|
| 234 |
---
|
| 235 |
|
| 236 |
+
## Test Coverage
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
|
| 238 |
+
| Suite | Tests | Notes |
|
| 239 |
+
|-------|-------|-------|
|
| 240 |
+
| Phase 1 (M01-M13, X01-X04) | 13 | Core bus, routing, emergency, snapshot |
|
| 241 |
+
| Phase 2 (M14-M25, X05-X07) | 23 | Crypto, tokens, federation, OCR, chat, DHT |
|
| 242 |
+
| Phase 3 experimental | 15 | Distributed inference, MoE, fedlearn, LoRa, evidence, civdef |
|
| 243 |
+
| E2E Playwright browser | 11 | All 6 tabs, API health, mobile viewport |
|
| 244 |
+
| **Total** | **62** | All passing |
|
| 245 |
|
| 246 |
---
|
| 247 |
|
| 248 |
+
## Contributing
|
|
|
|
|
|
|
| 249 |
|
| 250 |
+
See [tasks.md](tasks.md) for current status, known gaps, and next steps.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
|
| 252 |
+
Architecture decisions are documented in the `docs/` folder:
|
| 253 |
|
| 254 |
+
- [docs/00-OVERVIEW.md](docs/00-OVERVIEW.md) — system overview
|
| 255 |
+
- [docs/CAPABILITY_CONTRACT.md](docs/CAPABILITY_CONTRACT.md) — capability API contract
|
| 256 |
+
- [docs/GLOSSARY.md](docs/GLOSSARY.md) — terminology
|
| 257 |
+
- [docs/roadmap.md](docs/roadmap.md) — roadmap
|
| 258 |
+
- [docs/p2_p3/](docs/p2_p3/) — Phase 2 and 3 specs
|
| 259 |
|
| 260 |
---
|
| 261 |
|
| 262 |
<p align="center">
|
| 263 |
+
Built with open source models and the belief that communities should own their AI.
|
| 264 |
</p>
|
|
@@ -0,0 +1,1634 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# HearthNet — Implementation Reference
|
| 2 |
+
|
| 3 |
+
**Purpose:** complete inventory of every file, class, method, and function across the spec set.
|
| 4 |
+
Use this as a coding checklist. For *why* and behaviour → follow the spec link on each entry.
|
| 5 |
+
For *what to call it* and *what it returns* → this document is normative.
|
| 6 |
+
|
| 7 |
+
**Spec set:** see [`00-OVERVIEW.md`](00-OVERVIEW.md), [`GLOSSARY.md`](GLOSSARY.md), [`CAPABILITY_CONTRACT.md`](CAPABILITY_CONTRACT.md).
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## 0. Conventions (read first)
|
| 12 |
+
|
| 13 |
+
### 0.1 Type aliases — `hearthnet/types.py`
|
| 14 |
+
|
| 15 |
+
*Re-exported by every module that uses them. Never invent synonyms.* — [00-OVERVIEW §4.1](00-OVERVIEW.md)
|
| 16 |
+
|
| 17 |
+
| Alias | Underlying | Example |
|
| 18 |
+
|-------|------------|---------|
|
| 19 |
+
| `NodeID` | `str` | `"ed25519:7H4G-Y9KL-2P3M-X8QR"` (short) or full base64-url |
|
| 20 |
+
| `CommunityID` | `str` | full base64-url |
|
| 21 |
+
| `CapabilityName` | `str` | `"llm.chat"` |
|
| 22 |
+
| `Version` | `tuple[int, int]` | `(1, 0)` |
|
| 23 |
+
| `Lamport` | `int` | monotonic per community |
|
| 24 |
+
| `CID` | `str` | `"blake3:<hex>"` |
|
| 25 |
+
| `EventID` | `str` | ULID |
|
| 26 |
+
| `TraceID` | `str` | ULID |
|
| 27 |
+
| `SchemaHash` | `str` | `"blake3:<hex>"` |
|
| 28 |
+
| `WallClock` | `str` | `"2026-05-26T08:14:22Z"` |
|
| 29 |
+
| `Signature` | `str` | `"ed25519:<base64-url-nopad>"` |
|
| 30 |
+
| `Topic` | `str` | `"marketplace.post.created"` |
|
| 31 |
+
| `ErrorCode` | `Literal[...]` | see [CONTRACT §9](CAPABILITY_CONTRACT.md) |
|
| 32 |
+
| `TrustLevel` | `Literal["unknown","member","trusted","anchor"]` | |
|
| 33 |
+
| `Profile` | `Literal["anchor","hearth","spark","bridge"]` | |
|
| 34 |
+
| `Stability` | `Literal["experimental","beta","stable"]` | |
|
| 35 |
+
| `Mode` | `Literal["online","degraded","offline"]` | emergency state |
|
| 36 |
+
| `Category` | `Literal["offer","request","info","emergency"]` | marketplace post |
|
| 37 |
+
| `EventType` | `Literal[...]` | 19 values; see [X02 §3.1](cross-cutting/X02-events.md) |
|
| 38 |
+
|
| 39 |
+
### 0.2 Constants — `hearthnet/constants.py`
|
| 40 |
+
|
| 41 |
+
Hardcoded; never configurable. Complete list in [GLOSSARY.md §Defaults](GLOSSARY.md).
|
| 42 |
+
|
| 43 |
+
`MANIFEST_TTL_SECONDS=30`, `MANIFEST_REPUBLISH_INTERVAL_SECONDS=20`, `DISCOVERY_UDP_INTERVAL_SECONDS` (5 active / 30 stable), `EMERGENCY_PROBE_INTERVAL_ONLINE=10`, `EMERGENCY_PROBE_INTERVAL_OFFLINE=2`, `EMERGENCY_PROBE_TIMEOUT_SECONDS=2`, `EMERGENCY_TRANSITION_DEBOUNCE_SECONDS=30`, `CONNECTION_IDLE_SECONDS=60`, `RECONNECT_BACKOFF_CAP_SECONDS=30`, `STREAM_WINDOW_FRAMES=16`, `STREAM_ACK_INTERVAL_FRAMES=8`, `STREAM_ACK_TIMEOUT_SECONDS=5`, `RPC_DEFAULT_TIMEOUT_SECONDS=30`, `LLM_GENERATION_DEFAULT_TIMEOUT_SECONDS=120`, `CHUNK_SIZE_BYTES=262144`, `BLOB_GC_DISK_THRESHOLD=0.80`, `RAG_CHUNK_TOKENS=1000`, `RAG_CHUNK_OVERLAP_TOKENS=200`, `RAG_DEFAULT_K=5`, `RAG_MAX_K=20`, `HEALTH_WINDOW_CALLS=100`, `HEALTH_QUARANTINE_THRESHOLD=0.5`, `HEALTH_QUARANTINE_SECONDS=60`, `RATE_LIMIT_SOFT_RPS_PER_CAP=10`, `RATE_LIMIT_HARD_RPS_PER_CAP=100`, `RATE_LIMIT_SOFT_RPS_TOTAL=100`, `RATE_LIMIT_HARD_RPS_TOTAL=1000`, `EVENT_LOG_RETENTION_DAYS=30`, `SNAPSHOT_LAG_LAMPORT=1000`, `TRACE_RING_BUFFER=10000`, `LOG_RETENTION_DAYS=14`.
|
| 44 |
+
|
| 45 |
+
### 0.3 Naming rules
|
| 46 |
+
|
| 47 |
+
- Functions: `snake_case`, verb-first
|
| 48 |
+
- Classes: `PascalCase`, noun
|
| 49 |
+
- Constants: `SCREAMING_SNAKE`
|
| 50 |
+
- Async I/O: `async def`; no `async_` prefix on names
|
| 51 |
+
- Protocols: `PascalCase` ending in capability noun (`LlmBackend`, `Service`)
|
| 52 |
+
- Private: leading underscore (`_compute_canonical_json`)
|
| 53 |
+
|
| 54 |
+
### 0.4 Universal error code → wire code mapping
|
| 55 |
+
|
| 56 |
+
| Domain exception | Wire `ErrorCode` | HTTP |
|
| 57 |
+
|------------------|------------------|------|
|
| 58 |
+
| `IdentityError("invalid_signature")` | `invalid_signature` | 401 |
|
| 59 |
+
| `IdentityError("expired")` | `expired` | 410 |
|
| 60 |
+
| `BusError("schema_mismatch")` | `schema_mismatch` | 400 |
|
| 61 |
+
| `BusError("not_found")` | `not_found` | 404 |
|
| 62 |
+
| `BusError("capacity_exceeded")` | `capacity_exceeded` | 429 |
|
| 63 |
+
| `BusError("quarantined" / "partition")` | `partition` | 503 |
|
| 64 |
+
| `BusError("timeout")` | `timeout` | 408 |
|
| 65 |
+
| `EventLogError("invalid_signature")` | `invalid_signature` | — (internal) |
|
| 66 |
+
| `BlobError("not_found")` | `not_found` | 404 |
|
| 67 |
+
| `BlobError("hash_mismatch")` | `bad_request` | 400 |
|
| 68 |
+
| `BlobError("disk_full")` | `capacity_exceeded` | 429 |
|
| 69 |
+
| `OnboardingError(*)` | — (local UI only) | — |
|
| 70 |
+
| `ConfigError(*)` | — (startup) | — |
|
| 71 |
+
|
| 72 |
+
---
|
| 73 |
+
|
| 74 |
+
## 1. X04 — Configuration
|
| 75 |
+
|
| 76 |
+
**Spec:** [`cross-cutting/X04-config.md`](cross-cutting/X04-config.md) · **Path:** `hearthnet/config.py` + `hearthnet/constants.py`
|
| 77 |
+
|
| 78 |
+
### `hearthnet/config.py`
|
| 79 |
+
|
| 80 |
+
#### Dataclasses (all `@dataclass(frozen=True)`)
|
| 81 |
+
|
| 82 |
+
`IdentityConfig` — §3:
|
| 83 |
+
- `keys_dir: Path`
|
| 84 |
+
- `auto_generate: bool = True`
|
| 85 |
+
|
| 86 |
+
`CommunityConfig` — §3:
|
| 87 |
+
- `community_id: Optional[str] = None`
|
| 88 |
+
- `state_dir: Path = Path()`
|
| 89 |
+
|
| 90 |
+
`TransportConfig` — §3:
|
| 91 |
+
- `host: str = "0.0.0.0"`
|
| 92 |
+
- `port: int = 7080`
|
| 93 |
+
- `tls_cert: Optional[Path] = None`
|
| 94 |
+
- `tls_key: Optional[Path] = None`
|
| 95 |
+
|
| 96 |
+
`DiscoveryConfig` — §3:
|
| 97 |
+
- `mdns_enabled: bool = True`
|
| 98 |
+
- `udp_enabled: bool = True`
|
| 99 |
+
- `udp_multicast_group: str = "239.255.42.42"`
|
| 100 |
+
- `udp_port: int = 42424`
|
| 101 |
+
- `relay_urls: list[str] = []`
|
| 102 |
+
|
| 103 |
+
`BusConfig` — §3:
|
| 104 |
+
- `prefer_local: bool = True`
|
| 105 |
+
- `local_load_threshold: float = 0.80`
|
| 106 |
+
|
| 107 |
+
`LlmBackendConfig` — §3:
|
| 108 |
+
- `name: str` — one of `"llama_cpp" | "ollama" | "lmstudio" | "vllm" | "hf_api" | "anthropic_api" | "nemotron" | "openbmb"`
|
| 109 |
+
- `url: Optional[str] = None`
|
| 110 |
+
- `model: Optional[str] = None`
|
| 111 |
+
- `api_key_env: Optional[str] = None`
|
| 112 |
+
|
| 113 |
+
`LlmConfig` — §3:
|
| 114 |
+
- `backends: list[LlmBackendConfig] = []`
|
| 115 |
+
|
| 116 |
+
`EmbeddingConfig` — §3:
|
| 117 |
+
- `model: str = "BAAI/bge-small-en-v1.5"`
|
| 118 |
+
- `device: str = "auto"`
|
| 119 |
+
|
| 120 |
+
`RagConfig` — §3:
|
| 121 |
+
- `enabled: bool = True`
|
| 122 |
+
- `corpora_dir: Path = Path()`
|
| 123 |
+
|
| 124 |
+
`FileConfig` — §3:
|
| 125 |
+
- `blobs_dir: Path = Path()`
|
| 126 |
+
- `gc_threshold: float = 0.80`
|
| 127 |
+
|
| 128 |
+
`MarketConfig` — §3:
|
| 129 |
+
- `enabled: bool = True`
|
| 130 |
+
- `default_ttl_seconds: int = 604800`
|
| 131 |
+
- `max_ttl_seconds: int = 2592000`
|
| 132 |
+
|
| 133 |
+
`ChatConfig` — §3:
|
| 134 |
+
- `enabled: bool = True`
|
| 135 |
+
- `store_and_forward: bool = True`
|
| 136 |
+
|
| 137 |
+
`EmergencyConfig` — §3:
|
| 138 |
+
- `probe_targets: list[str] = ["1.1.1.1","8.8.8.8","cloudflare.com","quad9.net"]`
|
| 139 |
+
|
| 140 |
+
`UiConfig` — §3:
|
| 141 |
+
- `host: str = "127.0.0.1"`
|
| 142 |
+
- `port: int = 7860`
|
| 143 |
+
- `launch_browser: bool = True`
|
| 144 |
+
|
| 145 |
+
`ObservabilityConfig` — §3 (+ trackio addition):
|
| 146 |
+
- `log_level: str = "info"`
|
| 147 |
+
- `log_dir: Path = Path()`
|
| 148 |
+
- `metrics_enabled: bool = True`
|
| 149 |
+
- `otlp_endpoint: Optional[str] = None`
|
| 150 |
+
- `trackio_project: Optional[str] = None` — local trackio project name; enables trackio exporter when set
|
| 151 |
+
- `trackio_space: Optional[str] = None` — HF Space URL to mirror trackio runs to; optional
|
| 152 |
+
|
| 153 |
+
`Config` — §3:
|
| 154 |
+
- holds one of each of the above as named attributes
|
| 155 |
+
|
| 156 |
+
#### Functions
|
| 157 |
+
|
| 158 |
+
- `load(path: Path | None = None) -> Config` — §4. Read TOML, apply defaults, resolve paths, validate. Raises `ConfigError`.
|
| 159 |
+
- `default_config() -> Config` — §4. All-defaults Config.
|
| 160 |
+
- `save(config: Config, path: Path | None = None) -> None` — §4. Atomic TOML write.
|
| 161 |
+
- `resolve_paths(config: Config) -> Config` — §4. Resolve empty `Path()` to XDG locations. Idempotent.
|
| 162 |
+
- `validate(config: Config) -> None` — §4. Cross-field checks; raises `ConfigError`.
|
| 163 |
+
|
| 164 |
+
#### Exception
|
| 165 |
+
|
| 166 |
+
`ConfigError(Exception)` — §4:
|
| 167 |
+
- `__init__(code: str, **details)`
|
| 168 |
+
- `code: str`
|
| 169 |
+
- `details: dict`
|
| 170 |
+
|
| 171 |
+
### `hearthnet/constants.py`
|
| 172 |
+
|
| 173 |
+
Module-level constants from [GLOSSARY.md §Defaults](GLOSSARY.md). No classes; just `NAME = value` lines.
|
| 174 |
+
|
| 175 |
+
---
|
| 176 |
+
|
| 177 |
+
## 2. X03 — Observability
|
| 178 |
+
|
| 179 |
+
**Spec:** [`cross-cutting/X03-observability.md`](cross-cutting/X03-observability.md) · **Path:** `hearthnet/observability/`
|
| 180 |
+
|
| 181 |
+
### `hearthnet/observability/logging.py` — §3
|
| 182 |
+
|
| 183 |
+
#### Functions
|
| 184 |
+
|
| 185 |
+
- `configure(config: ObservabilityConfig) -> None` — install handlers + rotation; idempotent.
|
| 186 |
+
- `get_logger(name: str) -> logging.Logger` — JSON-formatted logger.
|
| 187 |
+
|
| 188 |
+
#### Class
|
| 189 |
+
|
| 190 |
+
`JsonFormatter(logging.Formatter)` — §3.1:
|
| 191 |
+
- `format(record: LogRecord) -> str` — emit `{"ts","level","logger","msg",**extras}`.
|
| 192 |
+
|
| 193 |
+
`RateLimitedLogger` — §3.2 (internal wrapper):
|
| 194 |
+
- `__init__(logger: Logger, per_key_seconds: float = 1.0)`
|
| 195 |
+
- `info(msg: str, key: str, **extras) -> None`
|
| 196 |
+
- `warning(msg: str, key: str, **extras) -> None`
|
| 197 |
+
|
| 198 |
+
### `hearthnet/observability/metrics.py` — §4
|
| 199 |
+
|
| 200 |
+
#### Functions
|
| 201 |
+
|
| 202 |
+
- `configure(config: ObservabilityConfig) -> None` — set up registries, start `/metrics` endpoint.
|
| 203 |
+
- `counter(name: str, doc: str, labels: list[str] = []) -> Counter`
|
| 204 |
+
- `histogram(name: str, doc: str, labels: list[str] = [], buckets: list[float] | None = None) -> Histogram`
|
| 205 |
+
- `gauge(name: str, doc: str, labels: list[str] = []) -> Gauge`
|
| 206 |
+
- `disabled() -> bool` — true when metrics are off.
|
| 207 |
+
|
| 208 |
+
#### Standard metric set — §4.2
|
| 209 |
+
|
| 210 |
+
Pre-registered at startup with these exact names:
|
| 211 |
+
`hearthnet_requests_total{capability,result}`, `hearthnet_request_duration_ms{capability,quantile}`, `hearthnet_active_streams{capability}`, `hearthnet_nodes_online{community}`, `hearthnet_event_log_size{community}`, `hearthnet_event_log_lamport_head{community}`, `hearthnet_emergency_mode{state}`, `hearthnet_blob_storage_bytes`, `hearthnet_llm_tokens_generated_total{model,backend}`, `hearthnet_llm_concurrent{model}`, `hearthnet_capability_health_success_rate{capability,node}`, `hearthnet_rate_limited_total{capability,reason}`, `hearthnet_signature_failures_total{reason}`, `hearthnet_quarantines_total`.
|
| 212 |
+
|
| 213 |
+
#### Trackio integration (new) — §4.4
|
| 214 |
+
|
| 215 |
+
`TrackioExporter` — optional. Activated when `config.observability.trackio_project` is set.
|
| 216 |
+
- `__init__(project: str, space: str | None = None)` — opens a trackio run.
|
| 217 |
+
- `record_call(capability: str, model: str | None, latency_ms: float, tokens_in: int | None, tokens_out: int | None, result: str) -> None` — logs one inference call as a step in the run.
|
| 218 |
+
- `record_topology_snapshot(snapshot: TopologySnapshot) -> None` — periodic mesh health log.
|
| 219 |
+
- `close() -> None`
|
| 220 |
+
|
| 221 |
+
Use trackio when you want a Gradio-native dashboard for run/inference history (alternative or complement to Prometheus). Bridged into `TraceHook.on_call_end` when active.
|
| 222 |
+
|
| 223 |
+
### `hearthnet/observability/tracing.py` — §5
|
| 224 |
+
|
| 225 |
+
#### Dataclasses
|
| 226 |
+
|
| 227 |
+
`Trace` — §5.1:
|
| 228 |
+
- `trace_id: str` (ULID)
|
| 229 |
+
- `capability: str`
|
| 230 |
+
- `started_at: float`
|
| 231 |
+
- `spans: list[Span]`
|
| 232 |
+
|
| 233 |
+
`Span` — §5.1:
|
| 234 |
+
- `name: str`
|
| 235 |
+
- `started_at: float`
|
| 236 |
+
- `ended_at: float | None`
|
| 237 |
+
- `extras: dict`
|
| 238 |
+
|
| 239 |
+
#### Functions
|
| 240 |
+
|
| 241 |
+
- `new_trace(capability: str) -> Trace` — open a new trace, attach to current task.
|
| 242 |
+
- `current_trace() -> Trace | None`
|
| 243 |
+
- `attach(trace: Trace) -> None`
|
| 244 |
+
- `detach() -> None` — close the current trace, push to ring buffer.
|
| 245 |
+
- `span(name: str, **extras) -> AbstractAsyncContextManager[Span]` — open a sub-span.
|
| 246 |
+
- `get_recent(n: int = 100) -> list[Trace]` — read from ring buffer (size `TRACE_RING_BUFFER`).
|
| 247 |
+
|
| 248 |
+
### `hearthnet/observability/doctor.py` — §6
|
| 249 |
+
|
| 250 |
+
#### Dataclass
|
| 251 |
+
|
| 252 |
+
`CheckResult` — §6.1:
|
| 253 |
+
- `name: str`
|
| 254 |
+
- `ok: bool`
|
| 255 |
+
- `detail: str`
|
| 256 |
+
- `fix: str | None`
|
| 257 |
+
|
| 258 |
+
#### Functions
|
| 259 |
+
|
| 260 |
+
- `register(name: str, check: Callable[[Config, CapabilityBus], CheckResult]) -> None`
|
| 261 |
+
- `run_all(config: Config, bus: CapabilityBus) -> list[CheckResult]`
|
| 262 |
+
- `run_one(name: str, config: Config, bus: CapabilityBus) -> CheckResult`
|
| 263 |
+
|
| 264 |
+
#### Standard checks (registered at startup) — §6.2
|
| 265 |
+
|
| 266 |
+
`keys_present`, `keys_loadable`, `community_present`, `event_log_writable`, `mdns_socket`, `udp_multicast`, `transport_port`, `at_least_one_capability`, `disk_space`, `clock_sanity`, `llm_backend_reachable`, `recent_error_rate`.
|
| 267 |
+
|
| 268 |
+
---
|
| 269 |
+
|
| 270 |
+
## 3. X02 — Events
|
| 271 |
+
|
| 272 |
+
**Spec:** [`cross-cutting/X02-events.md`](cross-cutting/X02-events.md) · **Path:** `hearthnet/events/`
|
| 273 |
+
|
| 274 |
+
### `hearthnet/events/types.py` — §3.1
|
| 275 |
+
|
| 276 |
+
`EventType` — Literal of 19 strings, exactly:
|
| 277 |
+
`community.created`, `community.member.invited`, `community.member.joined`, `community.member.revoked`, `community.member.promoted`, `community.member.demoted`, `community.policy.updated`, `node.manifest.updated`, `market.post.created`, `market.post.updated`, `market.post.expired`, `chat.message.sent`, `chat.message.delivered`, `chat.message.read`, `file.cid.advertised`, `file.cid.unpinned`, `rag.document.ingested`, `federation.peer.added`, `federation.peer.removed`.
|
| 278 |
+
|
| 279 |
+
`Event` *(frozen dataclass)*:
|
| 280 |
+
- `schema_version: int`
|
| 281 |
+
- `event_id: str`
|
| 282 |
+
- `lamport: int`
|
| 283 |
+
- `wall_clock: str`
|
| 284 |
+
- `community_id: str`
|
| 285 |
+
- `author: str`
|
| 286 |
+
- `event_type: EventType`
|
| 287 |
+
- `data: dict`
|
| 288 |
+
- `signature: str`
|
| 289 |
+
|
| 290 |
+
### `hearthnet/events/lamport.py` — §3.2
|
| 291 |
+
|
| 292 |
+
`LamportClock`:
|
| 293 |
+
- `__init__(conn: sqlite3.Connection, community_id: str)` — load current value.
|
| 294 |
+
- `current: int` *(property)*
|
| 295 |
+
- `tick_for_send() -> int` — increment + persist; returns new value.
|
| 296 |
+
- `observe(received_lamport: int) -> None` — `max(current, received) + 1`.
|
| 297 |
+
|
| 298 |
+
### `hearthnet/events/log.py` — §3.3
|
| 299 |
+
|
| 300 |
+
`EventLog`:
|
| 301 |
+
- `__init__(db_path: Path, community_id: str)` — open/create SQLite (WAL); apply schema.
|
| 302 |
+
- `append_local(event_type: EventType, data: dict, author_kp: KeyPair) -> Event` — mint, sign, persist, fan out.
|
| 303 |
+
- `append_received(event: Event) -> bool` — verify, persist if new. Returns True if new.
|
| 304 |
+
- `head() -> int` — highest Lamport.
|
| 305 |
+
- `get(event_id: str) -> Event | None`
|
| 306 |
+
- `replay(*, since_lamport: int = 0, event_types: list[EventType] | None = None, limit: int | None = None) -> Iterator[Event]`
|
| 307 |
+
- `heads_by_type() -> dict[EventType, int]`
|
| 308 |
+
- `subscribe(event_types: list[EventType] | None = None) -> AsyncIterator[Event]`
|
| 309 |
+
|
| 310 |
+
`EventLogError(Exception)`:
|
| 311 |
+
- `code in {"invalid_signature","out_of_order","unknown_author","revoked_author","schema_unknown","db_corrupt"}`
|
| 312 |
+
|
| 313 |
+
### `hearthnet/events/replay.py` — §3.4
|
| 314 |
+
|
| 315 |
+
`MaterialisedView` *(Protocol)*:
|
| 316 |
+
- `reset() -> None`
|
| 317 |
+
- `apply(event: Event) -> None`
|
| 318 |
+
- `snapshot_state() -> dict`
|
| 319 |
+
- `restore_state(state: dict) -> None`
|
| 320 |
+
|
| 321 |
+
`ReplayEngine`:
|
| 322 |
+
- `__init__(log: EventLog)`
|
| 323 |
+
- `register(name: str, view: MaterialisedView, event_types: list[EventType]) -> None`
|
| 324 |
+
- `rebuild(view_name: str, from_lamport: int = 0) -> None`
|
| 325 |
+
- `rebuild_all() -> None`
|
| 326 |
+
- `on_event(event: Event) -> None` — wired from `EventLog`.
|
| 327 |
+
|
| 328 |
+
### `hearthnet/events/snapshot.py` — §3.5
|
| 329 |
+
|
| 330 |
+
`Snapshot` *(frozen dataclass)*:
|
| 331 |
+
- `schema_version: int`
|
| 332 |
+
- `community_id: str`
|
| 333 |
+
- `lamport: int`
|
| 334 |
+
- `wall_clock: str`
|
| 335 |
+
- `state: dict`
|
| 336 |
+
- `covers_events_up_to: int`
|
| 337 |
+
- `signature: str`
|
| 338 |
+
|
| 339 |
+
`SnapshotStore`:
|
| 340 |
+
- `__init__(dir_path: Path, community_id: str)`
|
| 341 |
+
- `latest() -> Snapshot | None`
|
| 342 |
+
- `write(snap: Snapshot) -> None` — atomic.
|
| 343 |
+
- `list() -> list[int]`
|
| 344 |
+
- `prune(keep_last_n: int = 7) -> None`
|
| 345 |
+
|
| 346 |
+
Free functions:
|
| 347 |
+
- `build_snapshot(log: EventLog, engine: ReplayEngine, signing_kp: KeyPair, at_lamport: int | None = None) -> Snapshot`
|
| 348 |
+
- `restore_from_snapshot(snap: Snapshot, engine: ReplayEngine, log: EventLog) -> None`
|
| 349 |
+
|
| 350 |
+
### `hearthnet/events/sync.py` — §3.6
|
| 351 |
+
|
| 352 |
+
`HeadsReport` *(frozen dataclass)*:
|
| 353 |
+
- `community_id: str`
|
| 354 |
+
- `heads_by_type: dict[EventType, int]`
|
| 355 |
+
- `head: int`
|
| 356 |
+
|
| 357 |
+
`SyncResult` *(frozen dataclass)*:
|
| 358 |
+
- `sent_count: int`
|
| 359 |
+
- `received_count: int`
|
| 360 |
+
- `duration_ms: int`
|
| 361 |
+
|
| 362 |
+
`SyncClient`:
|
| 363 |
+
- `__init__(log: EventLog, transport_client: HttpClient)`
|
| 364 |
+
- `sync_with(peer_endpoint: Endpoint) -> SyncResult` *(async)*
|
| 365 |
+
- `run_round(peer_registry: PeerRegistry) -> list[SyncResult]` *(async)* — sync against all known peers.
|
| 366 |
+
|
| 367 |
+
`SyncServer`:
|
| 368 |
+
- `__init__(log: EventLog)`
|
| 369 |
+
- `serve_heads() -> HeadsReport` *(async)*
|
| 370 |
+
- `serve_events(events: list[Event]) -> dict` *(async)*
|
| 371 |
+
|
| 372 |
+
---
|
| 373 |
+
|
| 374 |
+
## 4. X01 — Transport
|
| 375 |
+
|
| 376 |
+
**Spec:** [`cross-cutting/X01-transport.md`](cross-cutting/X01-transport.md) · **Path:** `hearthnet/transport/`
|
| 377 |
+
|
| 378 |
+
### `hearthnet/transport/server.py` — §3
|
| 379 |
+
|
| 380 |
+
`HttpServer`:
|
| 381 |
+
- `__init__(config: TransportConfig, kp: KeyPair, bus: CapabilityBus, event_sync: SyncServer, community_manifest_provider: Callable[[], CommunityManifest])`
|
| 382 |
+
- `app() -> FastAPI` — for tests.
|
| 383 |
+
- `run() -> None` *(async)* — block, serve.
|
| 384 |
+
- `shutdown() -> None` *(async)*
|
| 385 |
+
|
| 386 |
+
#### Mounted endpoints — §3.2
|
| 387 |
+
|
| 388 |
+
`POST /bus/v1/call`, `GET /manifest`, `GET /community/manifest`, `GET /sync/v1/heads`, `POST /sync/v1/events`, `GET /pubsub/v1/subscribe`, `GET /health`, `GET /ready`, `GET /metrics`, `GET /trace/recent`.
|
| 389 |
+
|
| 390 |
+
`PubSubServer` — §8:
|
| 391 |
+
- `publish(topic: str, payload: dict) -> None` *(async)*
|
| 392 |
+
- `subscribe(topic: str, *, last_seq: int = 0, timeout_seconds: float = 30) -> dict` *(async)* — long-poll.
|
| 393 |
+
|
| 394 |
+
### `hearthnet/transport/client.py` — §5
|
| 395 |
+
|
| 396 |
+
`HttpClient`:
|
| 397 |
+
- `__init__(kp: KeyPair, node_id: str, community_id: str, pinned_certs: PinnedCerts, timeout_default_seconds: float = RPC_DEFAULT_TIMEOUT_SECONDS)`
|
| 398 |
+
- `call(peer: Endpoint, capability: str, version: str, body: dict, *, trace_id: str | None = None, timeout_seconds: float | None = None) -> dict` *(async)* — signed RPC.
|
| 399 |
+
- `stream(peer: Endpoint, capability: str, version: str, body: dict, *, trace_id: str | None = None, cancel: asyncio.Event | None = None) -> AsyncIterator[Frame]` — signed stream.
|
| 400 |
+
- `close() -> None` *(async)*
|
| 401 |
+
|
| 402 |
+
`CallError(Exception)`:
|
| 403 |
+
- `code: ErrorCode`
|
| 404 |
+
- `message: str`
|
| 405 |
+
- `retry_after_ms: int | None`
|
| 406 |
+
- `alt_capabilities: list[str]`
|
| 407 |
+
- `alt_nodes: list[str]`
|
| 408 |
+
|
| 409 |
+
### `hearthnet/transport/streams.py` — §6
|
| 410 |
+
|
| 411 |
+
`Frame` *(frozen dataclass)*:
|
| 412 |
+
- `event: str` — `"token" | "chunk" | "progress" | "ack" | "done" | "error" | "manifest" | "ready" | "tool_call_delta"`
|
| 413 |
+
- `data: dict`
|
| 414 |
+
- `seq: int`
|
| 415 |
+
|
| 416 |
+
`SseWriter`:
|
| 417 |
+
- `__init__(response: StreamingResponse)`
|
| 418 |
+
- `emit(event: str, data: dict) -> None` *(async)*
|
| 419 |
+
- `emit_token(token: dict) -> None` *(async)*
|
| 420 |
+
- `emit_progress(current: int, total: int, stage: str) -> None` *(async)*
|
| 421 |
+
- `emit_error(code: ErrorCode, **kwargs) -> None` *(async)*
|
| 422 |
+
- `emit_done(**meta) -> None` *(async)*
|
| 423 |
+
- `emit_ack(upto: int) -> None` *(async)*
|
| 424 |
+
- `cancelled: bool` *(property)*
|
| 425 |
+
|
| 426 |
+
`SseReader`:
|
| 427 |
+
- `__aiter__() -> AsyncIterator[Frame]`
|
| 428 |
+
- `cancel() -> None` *(async)*
|
| 429 |
+
|
| 430 |
+
### `hearthnet/transport/backpressure.py` — §6.3
|
| 431 |
+
|
| 432 |
+
`FlowControl`:
|
| 433 |
+
- `__init__(window: int = STREAM_WINDOW_FRAMES, ack_interval: int = STREAM_ACK_INTERVAL_FRAMES)`
|
| 434 |
+
- `window_used: int` *(property)*
|
| 435 |
+
- `send() -> None` *(async)* — await if window full.
|
| 436 |
+
- `ack(upto: int) -> None`
|
| 437 |
+
- `needs_ack: bool` *(property)*
|
| 438 |
+
|
| 439 |
+
### `hearthnet/transport/tls.py` — §4
|
| 440 |
+
|
| 441 |
+
`PinnedCerts`:
|
| 442 |
+
- `__init__(db_path: Path)`
|
| 443 |
+
- `record(node_id: str, fingerprint: bytes) -> None`
|
| 444 |
+
- `expected(node_id: str) -> bytes | None`
|
| 445 |
+
- `verify(node_id: str, presented: bytes) -> bool`
|
| 446 |
+
|
| 447 |
+
### `hearthnet/transport/__init__.py` — §7
|
| 448 |
+
|
| 449 |
+
`RateCheck` *(frozen dataclass)*:
|
| 450 |
+
- `allowed: bool`
|
| 451 |
+
- `soft_exceeded: bool`
|
| 452 |
+
- `retry_after_ms: int`
|
| 453 |
+
|
| 454 |
+
`RateLimiter`:
|
| 455 |
+
- `__init__(config: TransportConfig)`
|
| 456 |
+
- `check(peer_node_id: str, capability: str) -> RateCheck`
|
| 457 |
+
|
| 458 |
+
---
|
| 459 |
+
|
| 460 |
+
## 5. M01 — Identity & Manifests
|
| 461 |
+
|
| 462 |
+
**Spec:** [`modules/M01-identity.md`](modules/M01-identity.md) · **Path:** `hearthnet/identity/`
|
| 463 |
+
|
| 464 |
+
### `hearthnet/identity/keys.py` — §3.1
|
| 465 |
+
|
| 466 |
+
#### Class
|
| 467 |
+
|
| 468 |
+
`KeyPair` *(frozen dataclass)*:
|
| 469 |
+
- `signing_key: nacl.signing.SigningKey`
|
| 470 |
+
- `verify_key: nacl.signing.VerifyKey`
|
| 471 |
+
- `node_id_full: str`
|
| 472 |
+
- `node_id_short: str`
|
| 473 |
+
- `sign(payload: dict) -> dict` — returns `payload` + `signature` field.
|
| 474 |
+
- `sign_bytes(data: bytes) -> Signature`
|
| 475 |
+
|
| 476 |
+
#### Functions
|
| 477 |
+
|
| 478 |
+
- `generate() -> KeyPair`
|
| 479 |
+
- `load(keys_dir: Path) -> KeyPair` — raises `IdentityError("keys_missing"|"keys_invalid"|"keys_permissions")`.
|
| 480 |
+
- `load_or_generate(keys_dir: Path) -> KeyPair`
|
| 481 |
+
- `save(kp: KeyPair, keys_dir: Path) -> None` — 0600 perms.
|
| 482 |
+
- `short_node_id(verify_key_bytes: bytes) -> str` — `"ed25519:XXXX-XXXX-XXXX-XXXX"`.
|
| 483 |
+
- `full_node_id(verify_key_bytes: bytes) -> str` — `"ed25519:<base64-url-nopad>"`.
|
| 484 |
+
- `parse_node_id(node_id: str) -> bytes` — accepts only full form.
|
| 485 |
+
- `verify_key_from_full(node_id_full: str) -> VerifyKey`
|
| 486 |
+
- `canonical_json(obj: Any) -> bytes` — sorted, no whitespace, no trailing zeros, UTF-8.
|
| 487 |
+
- `sign_payload(payload: dict, kp: KeyPair) -> dict`
|
| 488 |
+
- `verify_payload(payload: dict, vk: VerifyKey) -> bool`
|
| 489 |
+
- `verify_payload_with_node_id(payload: dict, expected_node_id_full: str) -> bool`
|
| 490 |
+
- `generate_self_signed_cert(kp: KeyPair, host: str = "0.0.0.0") -> tuple[bytes, bytes]` — `(cert_pem, key_pem)`, 10-year validity.
|
| 491 |
+
|
| 492 |
+
#### Exception
|
| 493 |
+
|
| 494 |
+
`IdentityError(Exception)`:
|
| 495 |
+
- `code in {"keys_missing","keys_invalid","keys_permissions","bad_node_id","sign_failed","verify_failed","bad_manifest","expired","invalid_signature"}`
|
| 496 |
+
|
| 497 |
+
### `hearthnet/identity/manifest.py` — §3.2
|
| 498 |
+
|
| 499 |
+
#### Dataclasses
|
| 500 |
+
|
| 501 |
+
`Endpoint` *(frozen)*: `transport: str`, `host: str`, `port: int`.
|
| 502 |
+
|
| 503 |
+
`HardwareSpec` *(frozen)*: `gpu: str | None`, `vram_gb: float`, `ram_gb: float`, `cpu_cores: int`, `disk_free_gb: float`.
|
| 504 |
+
|
| 505 |
+
`CapabilitySpec` *(frozen)* — subset of `CapabilityDescriptor` for manifest embedding:
|
| 506 |
+
- `name: str`, `version: str`, `stability: str`, `schema_hash: str`, `params: dict`, `max_concurrent: int`.
|
| 507 |
+
|
| 508 |
+
`NodeManifest` *(frozen)*:
|
| 509 |
+
- `version: int`, `contract_version: str`, `node_id: str`, `display_name: str`, `community_id: str`, `profile: str`, `endpoints: list[Endpoint]`, `hardware: HardwareSpec`, `capabilities: list[CapabilitySpec]`, `uptime_seconds: int`, `load: dict`, `issued_at: str`, `expires_at: str`, `signature: str`.
|
| 510 |
+
- `as_dict() -> dict`
|
| 511 |
+
- `is_expired(now: datetime | None = None) -> bool`
|
| 512 |
+
|
| 513 |
+
`CommunityPolicy` *(frozen)*:
|
| 514 |
+
- `min_signatures_to_invite: int`
|
| 515 |
+
- `min_signatures_to_demote: int`
|
| 516 |
+
- `min_signatures_to_revoke: int`
|
| 517 |
+
- `capability_token_ttl_seconds: int`
|
| 518 |
+
- `federation_enabled: bool`
|
| 519 |
+
- `default_member_can_invite: bool`
|
| 520 |
+
|
| 521 |
+
`CommunityMember` *(frozen)*: `node_id: str`, `level: TrustLevel`, `added_at: str`, `added_by: str`.
|
| 522 |
+
|
| 523 |
+
`RevokedEntry` *(frozen)*: `node_id: str`, `revoked_at: str`.
|
| 524 |
+
|
| 525 |
+
`CommunityManifest` *(frozen)*:
|
| 526 |
+
- `version: int`, `community_id: str`, `name: str`, `root_key: str`, `created_at: str`, `lamport_at_creation: int`, `policy: CommunityPolicy`, `members: list[CommunityMember]`, `revoked: list[RevokedEntry]`, `head_lamport: int`, `signature: str`.
|
| 527 |
+
- `is_member(node_id: str) -> bool`
|
| 528 |
+
- `level_of(node_id: str) -> TrustLevel | None`
|
| 529 |
+
- `is_revoked(node_id: str) -> bool`
|
| 530 |
+
|
| 531 |
+
#### Functions
|
| 532 |
+
|
| 533 |
+
- `build_node_manifest(kp: KeyPair, community_id: str, display_name: str, profile: str, endpoints: list[Endpoint], hardware: HardwareSpec, capabilities: list[CapabilitySpec], uptime_seconds: int, load: dict) -> NodeManifest`
|
| 534 |
+
- `parse_node_manifest(blob: bytes | dict) -> NodeManifest`
|
| 535 |
+
- `verify_node_manifest(manifest: NodeManifest, *, now: datetime | None = None) -> None`
|
| 536 |
+
- `build_community_manifest(root_kp: KeyPair, name: str, policy: CommunityPolicy) -> CommunityManifest`
|
| 537 |
+
- `regenerate_community_manifest_from_state(materialised_state: dict, signing_kp: KeyPair) -> CommunityManifest`
|
| 538 |
+
- `parse_community_manifest(blob: bytes | dict) -> CommunityManifest`
|
| 539 |
+
- `verify_community_manifest(cm: CommunityManifest) -> None`
|
| 540 |
+
- `load_or_regenerate(state_dir: Path, signing_kp: KeyPair | None = None) -> CommunityManifest` — convenience used by `node.py`.
|
| 541 |
+
|
| 542 |
+
### `hearthnet/identity/tokens.py` — §3.3 *(Phase 2; stub in MVP)*
|
| 543 |
+
|
| 544 |
+
`CapabilityToken` *(frozen)*: `issuer: str`, `subject: str`, `capability: str`, `issued_at: str`, `expires_at: str`, `nonce: str`, `signature: str`.
|
| 545 |
+
|
| 546 |
+
Functions (stubs):
|
| 547 |
+
- `issue_token(issuer_kp: KeyPair, subject_node_id: str, capability: str, ttl_seconds: int = 86400) -> CapabilityToken`
|
| 548 |
+
- `verify_token(token: CapabilityToken, expected_issuer: str) -> None`
|
| 549 |
+
|
| 550 |
+
---
|
| 551 |
+
|
| 552 |
+
## 6. M02 — Discovery
|
| 553 |
+
|
| 554 |
+
**Spec:** [`modules/M02-discovery.md`](modules/M02-discovery.md) · **Path:** `hearthnet/discovery/`
|
| 555 |
+
|
| 556 |
+
### `hearthnet/discovery/peers.py` — §3.1
|
| 557 |
+
|
| 558 |
+
`PeerRecord` *(dataclass)*:
|
| 559 |
+
- `node_id: str` (short), `node_id_full: str`, `display_name: str`, `community_id: str`, `profile: Profile`, `endpoints: list[Endpoint]`, `manifest: NodeManifest | None`, `last_seen: float`, `rtt_ms: float | None`, `source: str` (`"mdns"|"udp"|"relay"`).
|
| 560 |
+
|
| 561 |
+
`PeerEvent` *(frozen)*: `kind: str` (`"added"|"removed"|"updated"`), `peer: PeerRecord`.
|
| 562 |
+
|
| 563 |
+
`PeerRegistry`:
|
| 564 |
+
- `__init__(our_node_id_full: str, community_id: str)`
|
| 565 |
+
- `upsert(record: PeerRecord) -> bool` — True if new.
|
| 566 |
+
- `remove(node_id_full: str) -> bool`
|
| 567 |
+
- `get(node_id_full: str) -> PeerRecord | None`
|
| 568 |
+
- `all() -> list[PeerRecord]`
|
| 569 |
+
- `for_community(community_id: str) -> list[PeerRecord]`
|
| 570 |
+
- `prune_stale(max_age_seconds: int = 90) -> int`
|
| 571 |
+
- `subscribe() -> AsyncIterator[PeerEvent]`
|
| 572 |
+
- `set_pruning_aggressive(enabled: bool) -> None` — toggled by M09; uses 30s when on, 90s when off.
|
| 573 |
+
|
| 574 |
+
### `hearthnet/discovery/mdns.py` — §3.2
|
| 575 |
+
|
| 576 |
+
`MdnsAnnouncer`:
|
| 577 |
+
- `__init__(kp: KeyPair, node_id_short: str, display_name: str, community_id_short: str, profile: Profile, port: int, capabilities_names: list[str], manifest_url: str)`
|
| 578 |
+
- `start() -> None` *(async)*
|
| 579 |
+
- `stop() -> None` *(async)*
|
| 580 |
+
- `update(*, capabilities_names: list[str] | None = None) -> None`
|
| 581 |
+
|
| 582 |
+
`MdnsBrowser`:
|
| 583 |
+
- `__init__(registry: PeerRegistry, our_community_id: str)`
|
| 584 |
+
- `start() -> None` *(async)*
|
| 585 |
+
- `stop() -> None` *(async)*
|
| 586 |
+
|
| 587 |
+
### `hearthnet/discovery/udp.py` — §3.4
|
| 588 |
+
|
| 589 |
+
`UdpAnnouncer`:
|
| 590 |
+
- `__init__(kp: KeyPair, registry: PeerRegistry, node_id_short: str, community_id_short: str, port: int, capabilities_names: list[str], multicast_group: str = "239.255.42.42", multicast_port: int = 42424)`
|
| 591 |
+
- `run() -> None` *(async)*
|
| 592 |
+
|
| 593 |
+
`UdpListener`:
|
| 594 |
+
- `__init__(registry: PeerRegistry, our_community_id: str)`
|
| 595 |
+
- `run() -> None` *(async)*
|
| 596 |
+
|
| 597 |
+
### `hearthnet/discovery/relay.py` *(Phase 2 stub)*
|
| 598 |
+
|
| 599 |
+
`InternetRelayClient` — not implemented in MVP. Reserved.
|
| 600 |
+
|
| 601 |
+
#### Exception
|
| 602 |
+
|
| 603 |
+
`DiscoveryError(Exception)`:
|
| 604 |
+
- `code in {"socket_in_use","mdns_unavailable","manifest_fetch_failed","manifest_invalid"}`
|
| 605 |
+
|
| 606 |
+
---
|
| 607 |
+
|
| 608 |
+
## 7. M03 — Capability Bus
|
| 609 |
+
|
| 610 |
+
**Spec:** [`modules/M03-bus.md`](modules/M03-bus.md) · **Path:** `hearthnet/bus/`
|
| 611 |
+
|
| 612 |
+
### `hearthnet/bus/capability.py` — §3.1
|
| 613 |
+
|
| 614 |
+
`CapabilityDescriptor` *(frozen dataclass)*:
|
| 615 |
+
- `name: CapabilityName`, `version: Version`, `stability: Stability`, `request_schema: dict`, `response_schema: dict | None`, `stream_schema: dict | None`, `params: dict`, `max_concurrent: int`, `trust_required: str` (`"member"|"trusted"|"anchor"|"self"`), `timeout_seconds: int`, `idempotent: bool`.
|
| 616 |
+
- `version_str -> str` *(property)*
|
| 617 |
+
- `schema_hash() -> str` — BLAKE3 of canonical-JSON of `{name, version, request_schema, response_schema, stream_schema}`.
|
| 618 |
+
|
| 619 |
+
`CapabilityEntry` *(dataclass)*:
|
| 620 |
+
- `node_id: str`, `descriptor: CapabilityDescriptor`, `is_local: bool`, `handler: Callable | None`, `endpoint: Endpoint | None`, `in_flight: int`, `last_seen: float`, `p50_latency_ms: float`, `p99_latency_ms: float`, `success_rate: float`, `quarantined_until: float`, `sticky_sessions: set[str]`.
|
| 621 |
+
|
| 622 |
+
`RouteRequest` *(frozen dataclass)*:
|
| 623 |
+
- `capability: CapabilityName`, `version_req: Version`, `body: dict`, `caller: str`, `trace_id: str`, `session_id: str | None`, `deadline_ms: int`, `stream: bool`.
|
| 624 |
+
|
| 625 |
+
`ParamsPredicate` — type alias: `Callable[[dict, dict], bool]`.
|
| 626 |
+
|
| 627 |
+
### `hearthnet/bus/registry.py` — §3.2
|
| 628 |
+
|
| 629 |
+
`Diff` *(frozen dataclass)*: `added`, `removed`, `updated` — each `list[CapabilityEntry]`.
|
| 630 |
+
|
| 631 |
+
`RegistryEvent` *(frozen dataclass)*: `kind: str` (`"added"|"removed"|"updated"`), `entry: CapabilityEntry`.
|
| 632 |
+
|
| 633 |
+
`Registry`:
|
| 634 |
+
- `__init__(our_node_id: str)`
|
| 635 |
+
- `register_local(descriptor: CapabilityDescriptor, handler: Callable, params_compatible: ParamsPredicate | None = None) -> None`
|
| 636 |
+
- `deregister_local(name: CapabilityName, version: Version) -> None`
|
| 637 |
+
- `update_from_peer_manifest(peer: PeerRecord, manifest: NodeManifest) -> Diff`
|
| 638 |
+
- `remove_peer(node_id: str) -> int`
|
| 639 |
+
- `find(name: CapabilityName, version_req: Version, params_filter: Callable[[dict], bool] | None = None) -> list[CapabilityEntry]`
|
| 640 |
+
- `entry(node_id: str, name: CapabilityName, version: Version) -> CapabilityEntry | None`
|
| 641 |
+
- `all_local() -> list[CapabilityEntry]`
|
| 642 |
+
- `all() -> list[CapabilityEntry]`
|
| 643 |
+
- `subscribe() -> AsyncIterator[RegistryEvent]`
|
| 644 |
+
|
| 645 |
+
### `hearthnet/bus/health.py` — §3.3
|
| 646 |
+
|
| 647 |
+
`HealthTracker`:
|
| 648 |
+
- `__init__(window: int = HEALTH_WINDOW_CALLS)`
|
| 649 |
+
- `record(entry: CapabilityEntry, *, success: bool, latency_ms: float) -> None`
|
| 650 |
+
- `is_quarantined(entry: CapabilityEntry) -> bool`
|
| 651 |
+
- `reset(entry: CapabilityEntry) -> None`
|
| 652 |
+
|
| 653 |
+
### `hearthnet/bus/schema.py` — §3.4
|
| 654 |
+
|
| 655 |
+
`SchemaValidator`:
|
| 656 |
+
- `__init__()`
|
| 657 |
+
- `validate_request(descriptor: CapabilityDescriptor, body: dict) -> None`
|
| 658 |
+
- `validate_response(descriptor: CapabilityDescriptor, body: dict) -> None`
|
| 659 |
+
- `validate_stream_frame(descriptor: CapabilityDescriptor, frame: dict) -> None`
|
| 660 |
+
|
| 661 |
+
Free function:
|
| 662 |
+
- `compute_schema_hash(descriptor_partial: dict) -> str` — `"blake3:<hex>"`. See [CONTRACT §11](CAPABILITY_CONTRACT.md).
|
| 663 |
+
|
| 664 |
+
### `hearthnet/bus/router.py` — §3.5
|
| 665 |
+
|
| 666 |
+
`Router`:
|
| 667 |
+
- `__init__(registry: Registry, config: BusConfig, our_node_id: str)`
|
| 668 |
+
- `route(req: RouteRequest) -> CapabilityEntry | None` — scoring algorithm; see §5.4.
|
| 669 |
+
- `route_sticky(req: RouteRequest) -> CapabilityEntry | None`
|
| 670 |
+
- `release_session(session_id: str) -> None`
|
| 671 |
+
|
| 672 |
+
### `hearthnet/bus/trace.py` — §3.6
|
| 673 |
+
|
| 674 |
+
`CallTraceEvent` *(frozen dataclass)*:
|
| 675 |
+
- `ts: str`, `trace_id: str`, `capability: CapabilityName`, `version: str`, `from_node: str`, `to_node: str`, `is_local: bool`, `result: str`, `ms: float`, `tokens_in: int | None`, `tokens_out: int | None`, `bytes_in: int`, `bytes_out: int`.
|
| 676 |
+
|
| 677 |
+
`TraceHook`:
|
| 678 |
+
- `__init__()`
|
| 679 |
+
- `on_call_start(req: RouteRequest, entry: CapabilityEntry) -> None`
|
| 680 |
+
- `on_call_end(req: RouteRequest, entry: CapabilityEntry, *, result: str, latency_ms: float, bytes_in: int, bytes_out: int, tokens_in: int | None = None, tokens_out: int | None = None) -> None`
|
| 681 |
+
|
| 682 |
+
### `hearthnet/bus/__init__.py` — §3.7
|
| 683 |
+
|
| 684 |
+
`TopologySnapshot` *(frozen dataclass)*:
|
| 685 |
+
- `our_node_id: str`, `peers: list[PeerRecord]`, `capabilities_local: list[CapabilityEntry]`, `capabilities_remote: list[CapabilityEntry]`, `in_flight_total: int`.
|
| 686 |
+
|
| 687 |
+
`CapabilityBus`:
|
| 688 |
+
- `__init__(node_id_full: str, community_id: str, config: BusConfig, transport_client: HttpClient, community_manifest_provider: Callable[[], CommunityManifest])`
|
| 689 |
+
- attributes: `registry`, `health`, `schema`, `router`, `trace`
|
| 690 |
+
- `register_service(service: Service) -> None`
|
| 691 |
+
- `register_capability(descriptor: CapabilityDescriptor, handler: Callable, params_compatible: ParamsPredicate | None = None) -> None`
|
| 692 |
+
- `handle_call(req: RouteRequest) -> dict | AsyncIterator[dict]` *(async)*
|
| 693 |
+
- `call(capability: CapabilityName, version_req: Version, body: dict, *, session_id: str | None = None, timeout_seconds: float | None = None) -> dict` *(async)*
|
| 694 |
+
- `stream(capability: CapabilityName, version_req: Version, body: dict, *, session_id: str | None = None) -> AsyncIterator[Frame]`
|
| 695 |
+
- `on_peer_added(peer: PeerRecord) -> None`
|
| 696 |
+
- `on_peer_updated(peer: PeerRecord) -> None`
|
| 697 |
+
- `on_peer_removed(node_id: str) -> None`
|
| 698 |
+
- `topology_snapshot() -> TopologySnapshot`
|
| 699 |
+
- `recent_traces(n: int = 50) -> list[CallTraceEvent]`
|
| 700 |
+
- `stats() -> dict`
|
| 701 |
+
|
| 702 |
+
`BusError(Exception)`:
|
| 703 |
+
- `code in {"schema_invalid","namespace_violation","schema_mismatch","not_found","capacity_exceeded","quarantined","partition","timeout","internal_error"}`
|
| 704 |
+
|
| 705 |
+
### `hearthnet/services/base.py` — M03 §4
|
| 706 |
+
|
| 707 |
+
`Service` *(Protocol)*:
|
| 708 |
+
- `name: str`
|
| 709 |
+
- `version: str`
|
| 710 |
+
- `capabilities() -> list[tuple[CapabilityDescriptor, Callable, ParamsPredicate]]`
|
| 711 |
+
- `start() -> None` *(async)*
|
| 712 |
+
- `stop() -> None` *(async)*
|
| 713 |
+
- `health() -> dict`
|
| 714 |
+
|
| 715 |
+
---
|
| 716 |
+
|
| 717 |
+
## 8. M11 — Embedding Service
|
| 718 |
+
|
| 719 |
+
**Spec:** [`modules/M11-embedding.md`](modules/M11-embedding.md) · **Path:** `hearthnet/services/embedding/`
|
| 720 |
+
|
| 721 |
+
### `hearthnet/services/embedding/backends.py` — §3.1
|
| 722 |
+
|
| 723 |
+
`EmbeddingBackend` *(Protocol)*:
|
| 724 |
+
- attrs: `name: str`, `model: str`, `dim: int`, `max_input: int`
|
| 725 |
+
- `embed(texts: list[str], *, normalize: bool = True) -> list[list[float]]` *(async)*
|
| 726 |
+
- `warm() -> None` *(async)*
|
| 727 |
+
- `close() -> None` *(async)*
|
| 728 |
+
- `health() -> dict`
|
| 729 |
+
|
| 730 |
+
`SentenceTransformerBackend`:
|
| 731 |
+
- `__init__(model: str, device: str = "auto")` — `device` ∈ `{"auto","cpu","cuda"}`.
|
| 732 |
+
- all `EmbeddingBackend` methods.
|
| 733 |
+
|
| 734 |
+
### `hearthnet/services/embedding/service.py` — §3.2
|
| 735 |
+
|
| 736 |
+
`EmbeddingService` *(implements `Service`)*:
|
| 737 |
+
- `name = "embedding"`, `version = "1.0"`
|
| 738 |
+
- `__init__(config: EmbeddingConfig)`
|
| 739 |
+
- `capabilities() -> [...]` — registers `embed.text@1.0`.
|
| 740 |
+
- `start()`, `stop()`, `health()` *(async)*
|
| 741 |
+
- `handle_embed_text(req: RouteRequest) -> dict` *(async)* — implements [CONTRACT §4.3](CAPABILITY_CONTRACT.md).
|
| 742 |
+
|
| 743 |
+
#### Capability params predicate
|
| 744 |
+
|
| 745 |
+
```python
|
| 746 |
+
def params_compatible(offered: dict, requested: dict) -> bool:
|
| 747 |
+
return requested.get("model") == offered.get("model")
|
| 748 |
+
```
|
| 749 |
+
|
| 750 |
+
---
|
| 751 |
+
|
| 752 |
+
## 9. M04 — LLM Service
|
| 753 |
+
|
| 754 |
+
**Spec:** [`modules/M04-llm.md`](modules/M04-llm.md) · **Path:** `hearthnet/services/llm/`
|
| 755 |
+
|
| 756 |
+
### `hearthnet/services/llm/backends/base.py` — §3.1
|
| 757 |
+
|
| 758 |
+
`Token` *(frozen dataclass)*: `text: str`, `logprob: float | None`, `stop: bool`.
|
| 759 |
+
|
| 760 |
+
`ChatResult` *(frozen dataclass)*: `text: str`, `tokens_in: int`, `tokens_out: int`, `stop_reason: str`, `ms: int`.
|
| 761 |
+
|
| 762 |
+
`BackendModel` *(frozen dataclass)*: `name: str`, `quant: str`, `ctx_max: int`, `modalities: list[str]`, `requires_internet: bool`.
|
| 763 |
+
|
| 764 |
+
`LlmBackend` *(Protocol)*:
|
| 765 |
+
- attrs: `name: str`, `models: list[BackendModel]`
|
| 766 |
+
- `warm(model: str) -> None` *(async)*
|
| 767 |
+
- `close() -> None` *(async)*
|
| 768 |
+
- `chat(*, model: str, messages: list[dict], max_tokens: int = 1024, temperature: float = 0.7, top_p: float = 0.95, stop: list[str] | None = None, seed: int | None = None, stream: bool = True) -> AsyncIterator[Token]`
|
| 769 |
+
- `complete(*, model: str, prompt: str, max_tokens: int = 256, temperature: float = 0.7, top_p: float = 0.95, stop: list[str] | None = None, seed: int | None = None, stream: bool = True) -> AsyncIterator[Token]`
|
| 770 |
+
- `count_tokens(model: str, text: str) -> int`
|
| 771 |
+
- `max_concurrent(model: str) -> int`
|
| 772 |
+
- `health() -> dict`
|
| 773 |
+
|
| 774 |
+
### Concrete backends — §3.2
|
| 775 |
+
|
| 776 |
+
Each implements `LlmBackend`. Same method set; only constructor varies.
|
| 777 |
+
|
| 778 |
+
| File | Class | Constructor signature |
|
| 779 |
+
|------|-------|-----------------------|
|
| 780 |
+
| `backends/llama_cpp.py` | `LlamaCppBackend` | `__init__(model_path: Path, model_meta: BackendModel, gpu_layers: int = -1)` |
|
| 781 |
+
| `backends/ollama.py` | `OllamaBackend` | `__init__(base_url: str = "http://localhost:11434", models: list[str] | None = None)` |
|
| 782 |
+
| `backends/lmstudio.py` | `LmStudioBackend` | `__init__(base_url: str, default_model: str)` — OpenAI-compatible HTTP |
|
| 783 |
+
| `backends/hf_api.py` | `HfApiBackend` | `__init__(model: str, token_env: str = "HF_TOKEN")` — `requires_internet=True` |
|
| 784 |
+
| `backends/anthropic_api.py` | `AnthropicApiBackend` | `__init__(model: str = "claude-sonnet-4-6", token_env: str = "ANTHROPIC_API_KEY")` — `requires_internet=True` |
|
| 785 |
+
| `backends/nemotron.py` *(new)* | `NemotronBackend` | `__init__(base_url: str = "https://integrate.api.nvidia.com/v1", model: str = "nvidia/llama-3.1-nemotron-70b-instruct", token_env: str = "NVIDIA_API_KEY", local: bool = False)` — OpenAI-compatible; `requires_internet=True` unless `local=True` (locally-hosted NIM endpoint) |
|
| 786 |
+
| `backends/openbmb.py` *(new)* | `OpenBmbBackend` | `__init__(base_url: str = "http://localhost:8000", model: str = "openbmb/MiniCPM4-8B", token_env: str | None = None)` — OpenAI-compatible HTTP (vLLM / llama.cpp serve / SGLang); `requires_internet=False`. Designed around Christof's MiniCPM workbench |
|
| 787 |
+
|
| 788 |
+
All backends declare their `models: list[BackendModel]` so the service can enumerate `(backend, model)` pairs at registration time.
|
| 789 |
+
|
| 790 |
+
### `hearthnet/services/llm/tokenizers.py` — §3.3
|
| 791 |
+
|
| 792 |
+
- `count_tokens_approx(model_family: str, text: str) -> int`
|
| 793 |
+
- `model_family(model_name: str) -> str` — e.g. `"qwen2.5-7b-instruct"` → `"qwen"`, `"nemotron-70b"` → `"nemotron"`, `"MiniCPM4-8B"` → `"minicpm"`.
|
| 794 |
+
|
| 795 |
+
### `hearthnet/services/llm/service.py` — §3.4
|
| 796 |
+
|
| 797 |
+
`LlmService` *(implements `Service`)*:
|
| 798 |
+
- `name = "llm"`, `version = "1.0"`
|
| 799 |
+
- `__init__(config: LlmConfig)`
|
| 800 |
+
- `_build_backends(config: LlmConfig) -> list[LlmBackend]`
|
| 801 |
+
- `capabilities() -> [...]` — emits one descriptor per `(backend, model)` × `{llm.chat, llm.complete}`.
|
| 802 |
+
- `start(), stop(), health()` *(async)*
|
| 803 |
+
- `handle_chat(req: RouteRequest) -> AsyncIterator[dict]` *(async)* — implements [CONTRACT §4.1](CAPABILITY_CONTRACT.md).
|
| 804 |
+
- `handle_complete(req: RouteRequest) -> AsyncIterator[dict]` *(async)* — implements [CONTRACT §4.2](CAPABILITY_CONTRACT.md).
|
| 805 |
+
|
| 806 |
+
#### Capability params predicate — §3.6
|
| 807 |
+
|
| 808 |
+
```python
|
| 809 |
+
def params_compatible(offered: dict, requested: dict) -> bool:
|
| 810 |
+
if requested.get("model") != offered.get("model"):
|
| 811 |
+
return False
|
| 812 |
+
if "ctx" in requested and requested["ctx"] > offered["ctx"]:
|
| 813 |
+
return False
|
| 814 |
+
return True
|
| 815 |
+
```
|
| 816 |
+
|
| 817 |
+
---
|
| 818 |
+
|
| 819 |
+
## 10. M05 — RAG Service
|
| 820 |
+
|
| 821 |
+
**Spec:** [`modules/M05-rag.md`](modules/M05-rag.md) · **Path:** `hearthnet/services/rag/`
|
| 822 |
+
|
| 823 |
+
### `hearthnet/services/rag/chunker.py` — §3.1
|
| 824 |
+
|
| 825 |
+
`Chunk` *(frozen dataclass)*: `text: str`, `metadata: dict`.
|
| 826 |
+
|
| 827 |
+
Functions:
|
| 828 |
+
- `chunk_text(text: str, *, tokens_per_chunk: int = RAG_CHUNK_TOKENS, overlap_tokens: int = RAG_CHUNK_OVERLAP_TOKENS, metadata: dict | None = None) -> list[Chunk]`
|
| 829 |
+
- `chunk_pdf(pdf_bytes: bytes, *, doc_metadata: dict) -> list[Chunk]`
|
| 830 |
+
|
| 831 |
+
### `hearthnet/services/rag/store.py` — §3.2
|
| 832 |
+
|
| 833 |
+
`ScoredChunk` *(frozen dataclass)*: `chunk: Chunk`, `score: float`.
|
| 834 |
+
|
| 835 |
+
`CorpusStore`:
|
| 836 |
+
- `__init__(corpora_dir: Path, corpus: str, embedding_dim: int)`
|
| 837 |
+
- `add_chunks(chunks: list[Chunk], embeddings: list[list[float]]) -> None`
|
| 838 |
+
- `has_document(doc_cid: str) -> bool`
|
| 839 |
+
- `query(embedding: list[float], *, k: int, filter: dict | None = None) -> list[ScoredChunk]`
|
| 840 |
+
- `count() -> int`
|
| 841 |
+
- `size_bytes() -> int`
|
| 842 |
+
- `language_majority() -> str | None`
|
| 843 |
+
|
| 844 |
+
Free functions:
|
| 845 |
+
- `list_corpora(corpora_dir: Path) -> list[str]`
|
| 846 |
+
- `corpus_info(corpora_dir: Path, corpus: str) -> dict`
|
| 847 |
+
|
| 848 |
+
### `hearthnet/services/rag/ingest.py` — §3.3
|
| 849 |
+
|
| 850 |
+
`IngestResult` *(frozen dataclass)*: `doc_cid: str`, `chunks_indexed: int`, `tokens_indexed: int`, `ingest_event_id: str`, `ms: int`.
|
| 851 |
+
|
| 852 |
+
`IngestPipeline`:
|
| 853 |
+
- `__init__(bus: CapabilityBus, blob_store: BlobStore, corpora_dir: Path, event_log: EventLog)`
|
| 854 |
+
- `ingest_document(doc_cid: str, corpus: str, title: str, language: str, metadata: dict, author_kp: KeyPair) -> IngestResult` *(async)*
|
| 855 |
+
|
| 856 |
+
### `hearthnet/services/rag/service.py` — §3.4
|
| 857 |
+
|
| 858 |
+
`RagService` *(implements `Service`)*:
|
| 859 |
+
- `name = "rag"`, `version = "1.0"`
|
| 860 |
+
- `__init__(config: RagConfig, bus: CapabilityBus, blob_store: BlobStore, event_log: EventLog, community_manifest_provider: Callable[[], CommunityManifest])`
|
| 861 |
+
- `capabilities() -> [...]` — `rag.query@1.0` per corpus, `rag.ingest@1.0` once, `rag.list_corpora@1.0` once.
|
| 862 |
+
- `start(), stop(), health()` *(async)*
|
| 863 |
+
- `handle_query(req: RouteRequest) -> dict` *(async)* — [CONTRACT §4.4](CAPABILITY_CONTRACT.md).
|
| 864 |
+
- `handle_ingest(req: RouteRequest) -> dict` *(async)* — [CONTRACT §4.5](CAPABILITY_CONTRACT.md).
|
| 865 |
+
- `handle_list_corpora(req: RouteRequest) -> dict` *(async)* — [CONTRACT §4.6](CAPABILITY_CONTRACT.md).
|
| 866 |
+
|
| 867 |
+
#### Capability params predicate — §3.5
|
| 868 |
+
|
| 869 |
+
```python
|
| 870 |
+
def query_params_compatible(offered: dict, requested: dict) -> bool:
|
| 871 |
+
return requested.get("corpus") == offered.get("corpus")
|
| 872 |
+
```
|
| 873 |
+
|
| 874 |
+
---
|
| 875 |
+
|
| 876 |
+
## 11. M07 — File & Blobs
|
| 877 |
+
|
| 878 |
+
**Spec:** [`modules/M07-file-blobs.md`](modules/M07-file-blobs.md) · **Paths:** `hearthnet/blobs/` + `hearthnet/services/file/`
|
| 879 |
+
|
| 880 |
+
### `hearthnet/blobs/chunker.py` — §3.1
|
| 881 |
+
|
| 882 |
+
`ChunkRef` *(frozen)*: `index: int`, `cid: str`, `size_bytes: int`.
|
| 883 |
+
|
| 884 |
+
`BlobManifest` *(frozen)*: `cid: str`, `size_bytes: int`, `chunk_size_bytes: int`, `chunks: list[ChunkRef]`, `mime_type: str | None`, `filename: str | None`.
|
| 885 |
+
|
| 886 |
+
Functions:
|
| 887 |
+
- `hash_bytes(data: bytes) -> str` — `"blake3:<hex>"`.
|
| 888 |
+
- `chunk_blob(data: bytes, *, chunk_size: int = CHUNK_SIZE_BYTES) -> tuple[BlobManifest, list[bytes]]`
|
| 889 |
+
- `manifest_cid(manifest: BlobManifest) -> str`
|
| 890 |
+
- `reassemble(chunks: list[bytes]) -> bytes`
|
| 891 |
+
- `verify_chunk(data: bytes, expected_cid: str) -> None` — raises `BlobError("hash_mismatch")`.
|
| 892 |
+
|
| 893 |
+
### `hearthnet/blobs/store.py` — §3.2
|
| 894 |
+
|
| 895 |
+
`BlobStore`:
|
| 896 |
+
- `__init__(dir_path: Path, gc_threshold: float = BLOB_GC_DISK_THRESHOLD)`
|
| 897 |
+
- `has(cid: str) -> bool`
|
| 898 |
+
- `read_chunk(cid: str) -> bytes`
|
| 899 |
+
- `write_chunk(cid: str, data: bytes) -> None`
|
| 900 |
+
- `delete_chunk(cid: str) -> bool`
|
| 901 |
+
- `has_blob(manifest_cid: str) -> bool`
|
| 902 |
+
- `read_manifest(manifest_cid: str) -> BlobManifest`
|
| 903 |
+
- `write_blob(manifest: BlobManifest, chunks: list[bytes]) -> None`
|
| 904 |
+
- `read_blob_bytes(manifest_cid: str) -> bytes`
|
| 905 |
+
- `read_blob_stream(manifest_cid: str) -> AsyncIterator[tuple[ChunkRef, bytes]]` *(async)*
|
| 906 |
+
- `list_cids(prefix: str | None = None) -> list[str]`
|
| 907 |
+
- `total_bytes() -> int`
|
| 908 |
+
- `pin(cid: str) -> None`
|
| 909 |
+
- `unpin(cid: str) -> None`
|
| 910 |
+
- `is_pinned(cid: str) -> bool`
|
| 911 |
+
- `gc(target_fraction: float = 0.7) -> int` — bytes freed.
|
| 912 |
+
|
| 913 |
+
`BlobError(Exception)`:
|
| 914 |
+
- `code in {"not_found","hash_mismatch","io_error","disk_full","manifest_invalid"}`
|
| 915 |
+
|
| 916 |
+
### `hearthnet/blobs/transfer.py` — §3.3
|
| 917 |
+
|
| 918 |
+
`TransferManager`:
|
| 919 |
+
- `__init__(store: BlobStore, bus: CapabilityBus, concurrency: int = 4)`
|
| 920 |
+
- `fetch_blob(manifest_cid: str, *, sources: list[str] | None = None) -> BlobManifest` *(async)*
|
| 921 |
+
- `advertise(cids: list[str]) -> None` *(async)*
|
| 922 |
+
|
| 923 |
+
### `hearthnet/services/file/service.py` — §4.1
|
| 924 |
+
|
| 925 |
+
`FileService` *(implements `Service`)*:
|
| 926 |
+
- `name = "file"`, `version = "1.0"`
|
| 927 |
+
- `__init__(config: FileConfig, store: BlobStore, event_log: EventLog)`
|
| 928 |
+
- `capabilities() -> [...]` — `file.read`, `file.list`, `file.advertise`, `file.put` (all `@1.0`).
|
| 929 |
+
- `start(), stop(), health()` *(async)*
|
| 930 |
+
- `handle_read(req: RouteRequest) -> AsyncIterator[dict] | dict` *(async)* — [CONTRACT §4.7](CAPABILITY_CONTRACT.md).
|
| 931 |
+
- `handle_list(req: RouteRequest) -> dict` *(async)* — [CONTRACT §4.8](CAPABILITY_CONTRACT.md).
|
| 932 |
+
- `handle_advertise(req: RouteRequest) -> dict` *(async)* — [CONTRACT §4.9](CAPABILITY_CONTRACT.md).
|
| 933 |
+
- `handle_put(req: RouteRequest) -> AsyncIterator[dict]` *(async)* — [CONTRACT §4.10](CAPABILITY_CONTRACT.md).
|
| 934 |
+
|
| 935 |
+
All four `file.*` use default `lambda offered, requested: True` as params predicate.
|
| 936 |
+
|
| 937 |
+
---
|
| 938 |
+
|
| 939 |
+
## 12. M06 — Marketplace Service
|
| 940 |
+
|
| 941 |
+
**Spec:** [`modules/M06-marketplace.md`](modules/M06-marketplace.md) · **Path:** `hearthnet/services/marketplace/`
|
| 942 |
+
|
| 943 |
+
### `hearthnet/services/marketplace/post.py` — §3.1
|
| 944 |
+
|
| 945 |
+
`Location` *(frozen dataclass)*: `lat: float`, `lng: float`, `label: str`.
|
| 946 |
+
|
| 947 |
+
`Post` *(frozen dataclass)*:
|
| 948 |
+
- `event_id: str`, `lamport: int`, `author: str`, `category: Category`, `title: str`, `body: str`, `location: Location | None`, `tags: list[str]`, `created_at: str`, `expires_at: str`, `expired_via_event_id: str | None`, `expiry_reason: str | None`.
|
| 949 |
+
- `is_expired(now: datetime | None = None) -> bool`
|
| 950 |
+
|
| 951 |
+
### `hearthnet/services/marketplace/views.py` — §3.2
|
| 952 |
+
|
| 953 |
+
`MarketplaceView` *(implements `MaterialisedView` from X02)*:
|
| 954 |
+
- `__init__()`
|
| 955 |
+
- `reset() -> None`
|
| 956 |
+
- `apply(event: Event) -> None`
|
| 957 |
+
- `snapshot_state() -> dict`
|
| 958 |
+
- `restore_state(state: dict) -> None`
|
| 959 |
+
- `list(*, category: Category | None = None, tags: list[str] | None = None, since_lamport: int = 0, limit: int = 50) -> list[Post]`
|
| 960 |
+
- `get(event_id: str) -> Post | None`
|
| 961 |
+
- `max_lamport() -> int`
|
| 962 |
+
- `all_active() -> list[Post]`
|
| 963 |
+
|
| 964 |
+
### `hearthnet/services/marketplace/service.py` — §3.3
|
| 965 |
+
|
| 966 |
+
`MarketplaceService` *(implements `Service`)*:
|
| 967 |
+
- `name = "marketplace"`, `version = "1.0"`
|
| 968 |
+
- `__init__(config: MarketConfig, bus: CapabilityBus, event_log: EventLog, replay_engine: ReplayEngine, author_kp: KeyPair, community_manifest_provider: Callable[[], CommunityManifest])`
|
| 969 |
+
- `capabilities() -> [...]` — `market.list`, `market.post`, `market.expire`, `market.search` (all `@1.0`).
|
| 970 |
+
- `start(), stop(), health()` *(async)* — start replays events and installs auto-expiry sweeper.
|
| 971 |
+
- `handle_list(req) -> dict` *(async)* — [CONTRACT §4.11](CAPABILITY_CONTRACT.md).
|
| 972 |
+
- `handle_post(req) -> dict` *(async)* — [CONTRACT §4.12](CAPABILITY_CONTRACT.md).
|
| 973 |
+
- `handle_expire(req) -> dict` *(async)* — [CONTRACT §4.13](CAPABILITY_CONTRACT.md).
|
| 974 |
+
- `handle_search(req) -> dict` *(async)* — [CONTRACT §4.14](CAPABILITY_CONTRACT.md).
|
| 975 |
+
- `_auto_expire_sweep() -> None` *(async)* — internal background task.
|
| 976 |
+
|
| 977 |
+
All four use default `lambda offered, requested: True` predicate.
|
| 978 |
+
|
| 979 |
+
---
|
| 980 |
+
|
| 981 |
+
## 13. M10 — Chat Service
|
| 982 |
+
|
| 983 |
+
**Spec:** [`modules/M10-chat.md`](modules/M10-chat.md) · **Path:** `hearthnet/services/chat/`
|
| 984 |
+
|
| 985 |
+
### `hearthnet/services/chat/views.py` — §3.1
|
| 986 |
+
|
| 987 |
+
`ChatMessage` *(frozen dataclass)*:
|
| 988 |
+
- `event_id: str`, `lamport: int`, `sender: str`, `recipient: str`, `body: str`, `attachments: list[dict]`, `created_at: str`, `delivered_at: str | None`, `read_at: str | None`.
|
| 989 |
+
|
| 990 |
+
`ChatView` *(implements `MaterialisedView`)*:
|
| 991 |
+
- `__init__(our_node_id_full: str)`
|
| 992 |
+
- `reset(), apply(event), snapshot_state(), restore_state(state)`
|
| 993 |
+
- `history_with(peer: str | None = None, *, since_lamport: int = 0, limit: int = 200) -> list[ChatMessage]`
|
| 994 |
+
- `peers() -> list[str]`
|
| 995 |
+
- `unread_count(peer: str) -> int`
|
| 996 |
+
|
| 997 |
+
### `hearthnet/services/chat/delivery.py` — §3.2
|
| 998 |
+
|
| 999 |
+
`DeliveryManager`:
|
| 1000 |
+
- `__init__(bus: CapabilityBus, event_log: EventLog, author_kp: KeyPair, peer_registry: PeerRegistry, config: ChatConfig)`
|
| 1001 |
+
- `deliver(message_event: Event) -> str` *(async)* — returns `"direct"|"forwarded"|"queued"`.
|
| 1002 |
+
- `on_local_message_arrived(message_event: Event) -> None` *(async)*
|
| 1003 |
+
- `on_pubsub_message(payload: dict) -> None` *(async)*
|
| 1004 |
+
|
| 1005 |
+
### `hearthnet/services/chat/service.py` — §3.3
|
| 1006 |
+
|
| 1007 |
+
`ChatService` *(implements `Service`)*:
|
| 1008 |
+
- `name = "chat"`, `version = "1.0"`
|
| 1009 |
+
- `__init__(config: ChatConfig, bus: CapabilityBus, event_log: EventLog, replay_engine: ReplayEngine, peer_registry: PeerRegistry, author_kp: KeyPair, our_node_id_full: str)`
|
| 1010 |
+
- `capabilities() -> [...]` — `chat.send@1.0` (member trust), `chat.history@1.0` (self trust).
|
| 1011 |
+
- `start(), stop(), health()` *(async)*
|
| 1012 |
+
- `handle_send(req) -> dict` *(async)* — [CONTRACT §4.15](CAPABILITY_CONTRACT.md).
|
| 1013 |
+
- `handle_history(req) -> dict` *(async)* — [CONTRACT §4.16](CAPABILITY_CONTRACT.md). Enforces `caller == our_node_id_full`.
|
| 1014 |
+
|
| 1015 |
+
---
|
| 1016 |
+
|
| 1017 |
+
## 14. M09 — Emergency Mode Detector
|
| 1018 |
+
|
| 1019 |
+
**Spec:** [`modules/M09-emergency.md`](modules/M09-emergency.md) · **Path:** `hearthnet/emergency/`
|
| 1020 |
+
|
| 1021 |
+
### `hearthnet/emergency/state.py` — §3.1
|
| 1022 |
+
|
| 1023 |
+
`EmergencyState` *(frozen dataclass)*:
|
| 1024 |
+
- `mode: Mode`, `since: WallClock`, `last_probe: WallClock`, `probe_results: dict[str, bool]`.
|
| 1025 |
+
|
| 1026 |
+
`StateBus`:
|
| 1027 |
+
- `__init__()`
|
| 1028 |
+
- `current() -> EmergencyState`
|
| 1029 |
+
- `subscribe() -> AsyncIterator[EmergencyState]`
|
| 1030 |
+
- `_emit(state: EmergencyState) -> None` *(internal)*
|
| 1031 |
+
|
| 1032 |
+
### `hearthnet/emergency/detector.py` — §3.2
|
| 1033 |
+
|
| 1034 |
+
`Detector`:
|
| 1035 |
+
- `__init__(config: EmergencyConfig, bus: CapabilityBus, state_bus: StateBus)`
|
| 1036 |
+
- `run() -> None` *(async)*
|
| 1037 |
+
- `shutdown() -> None` *(async)*
|
| 1038 |
+
- `_probe_dns(host: str) -> bool` *(async, internal)*
|
| 1039 |
+
- `_probe_http(url: str) -> bool` *(async, internal)*
|
| 1040 |
+
|
| 1041 |
+
State-transition effects (§5.2):
|
| 1042 |
+
- entering offline → deregister local capabilities whose descriptor `params.requires_internet == True`
|
| 1043 |
+
- entering online → re-register those backends
|
| 1044 |
+
- offline ↔ online → flip `peer_registry.set_pruning_aggressive(...)` (M02)
|
| 1045 |
+
|
| 1046 |
+
---
|
| 1047 |
+
|
| 1048 |
+
## 15. M08 — UI
|
| 1049 |
+
|
| 1050 |
+
**Spec:** [`modules/M08-ui.md`](modules/M08-ui.md) · **Path:** `hearthnet/ui/`
|
| 1051 |
+
|
| 1052 |
+
### `hearthnet/ui/app.py` — §3.1
|
| 1053 |
+
|
| 1054 |
+
`UiApp`:
|
| 1055 |
+
- `__init__(bus: CapabilityBus, state_bus: StateBus, config: UiConfig, node_id_short: str, community_name: str)`
|
| 1056 |
+
- `build() -> gr.Blocks`
|
| 1057 |
+
- `launch_async() -> None` *(async)*
|
| 1058 |
+
- `shutdown() -> None` *(async)*
|
| 1059 |
+
|
| 1060 |
+
Free function:
|
| 1061 |
+
- `build_ui(bus: CapabilityBus, state_bus: StateBus, config: UiConfig, **meta) -> UiApp`
|
| 1062 |
+
|
| 1063 |
+
### `hearthnet/ui/topology.py` — §3.2
|
| 1064 |
+
|
| 1065 |
+
`TopologyComponent`:
|
| 1066 |
+
- `__init__(bus: CapabilityBus)`
|
| 1067 |
+
- `render() -> gr.HTML`
|
| 1068 |
+
- `push_trace(event: CallTraceEvent) -> None`
|
| 1069 |
+
- `push_topology(snapshot: TopologySnapshot) -> None`
|
| 1070 |
+
|
| 1071 |
+
### `hearthnet/ui/theme.py` — §7
|
| 1072 |
+
|
| 1073 |
+
- `hearthnet_theme: gr.Theme` *(module-level constant)*
|
| 1074 |
+
- `emergency_theme: gr.Theme` *(module-level constant)*
|
| 1075 |
+
- CSS variables documented in spec §7
|
| 1076 |
+
|
| 1077 |
+
### `hearthnet/ui/tabs/`
|
| 1078 |
+
|
| 1079 |
+
Each file exports a builder function returning a `gr.Tab` or `gr.Blocks` fragment.
|
| 1080 |
+
|
| 1081 |
+
| File | Function | Spec |
|
| 1082 |
+
|------|----------|------|
|
| 1083 |
+
| `tabs/ask.py` | `build_ask_tab(bus: CapabilityBus) -> gr.Tab` | §5.1 |
|
| 1084 |
+
| `tabs/chat.py` | `build_chat_tab(bus: CapabilityBus, our_node_id_full: str) -> gr.Tab` | §5.3 |
|
| 1085 |
+
| `tabs/marketplace.py` | `build_marketplace_tab(bus: CapabilityBus) -> gr.Tab` | §5.4 |
|
| 1086 |
+
| `tabs/files.py` | `build_files_tab(bus: CapabilityBus) -> gr.Tab` | §5.5 |
|
| 1087 |
+
| `tabs/emergency.py` | `build_emergency_tab(bus: CapabilityBus, state_bus: StateBus) -> gr.Tab` | §5.6 |
|
| 1088 |
+
| `tabs/settings.py` | `build_settings_tab(bus: CapabilityBus, config: Config) -> gr.Tab` | §5.2 |
|
| 1089 |
+
|
| 1090 |
+
### `hearthnet/ui/mobile/` — §6
|
| 1091 |
+
|
| 1092 |
+
Static assets served at `/mobile/*` by [X01](cross-cutting/X01-transport.md):
|
| 1093 |
+
- `index.html` — single-page app
|
| 1094 |
+
- `app.js` — same bus API; uses signed requests via WebCrypto
|
| 1095 |
+
- `style.css`
|
| 1096 |
+
|
| 1097 |
+
---
|
| 1098 |
+
|
| 1099 |
+
## 16. M13 — Onboarding
|
| 1100 |
+
|
| 1101 |
+
**Spec:** [`modules/M13-onboarding.md`](modules/M13-onboarding.md) · **Path:** `hearthnet/ui/onboarding.py`
|
| 1102 |
+
|
| 1103 |
+
### `hearthnet/ui/onboarding.py` — §3.1
|
| 1104 |
+
|
| 1105 |
+
`InviteBlob` *(frozen dataclass)*:
|
| 1106 |
+
- `schema_version: int`, `community_id: str`, `community_name: str`, `inviter_node_id: str`, `invitee_node_id: str`, `initial_level: str`, `bootstrap_endpoints: list[Endpoint]`, `expires_at: str`, `signature: str`.
|
| 1107 |
+
|
| 1108 |
+
#### Functions
|
| 1109 |
+
|
| 1110 |
+
- `encode_invite(blob: InviteBlob) -> str` — `"hearthnet://v1/<base64>"`.
|
| 1111 |
+
- `decode_invite(text: str) -> InviteBlob`
|
| 1112 |
+
- `invite_to_qr_png(blob: InviteBlob, *, box_size: int = 8) -> bytes`
|
| 1113 |
+
- `create_community(name: str, policy: CommunityPolicy, kp: KeyPair, state_dir: Path, event_log: EventLog) -> CommunityManifest`
|
| 1114 |
+
- `make_invite(invitee_node_id_full: str, inviter_kp: KeyPair, community_manifest: CommunityManifest, bootstrap_endpoints: list[Endpoint], initial_level: str = "member", ttl_seconds: int = 86400) -> InviteBlob`
|
| 1115 |
+
- `redeem_invite(blob: InviteBlob, our_kp: KeyPair, transport_client: HttpClient, event_log: EventLog) -> CommunityManifest` *(async)*
|
| 1116 |
+
- `build_onboarding(config: Config, kp_provider: Callable[[], KeyPair]) -> gr.Blocks`
|
| 1117 |
+
|
| 1118 |
+
Exception:
|
| 1119 |
+
`OnboardingError(Exception)`:
|
| 1120 |
+
- `code in {"invite_invalid","invite_expired","invitee_mismatch","bootstrap_unreachable","community_manifest_invalid","sync_failed","already_member"}`
|
| 1121 |
+
|
| 1122 |
+
---
|
| 1123 |
+
|
| 1124 |
+
## 17. M12 — CLI & Orchestrator
|
| 1125 |
+
|
| 1126 |
+
**Spec:** [`modules/M12-cli.md`](modules/M12-cli.md) · **Paths:** `hearthnet/cli.py` + `hearthnet/node.py`
|
| 1127 |
+
|
| 1128 |
+
### `hearthnet/cli.py` — §3
|
| 1129 |
+
|
| 1130 |
+
Click group + subcommands. Each is a top-level function decorated with `@main.command()`.
|
| 1131 |
+
|
| 1132 |
+
| Command | Function | Spec |
|
| 1133 |
+
|---------|----------|------|
|
| 1134 |
+
| (root) | `main(ctx, config)` | §4 |
|
| 1135 |
+
| `init` | `init(name: str, profile: str, non_interactive: bool)` | §3.1 |
|
| 1136 |
+
| `run` | `run(config: str, no_ui: bool, debug: bool)` | §3.2 |
|
| 1137 |
+
| `status` | `status(json_output: bool)` | §3.3 |
|
| 1138 |
+
| `caps` | `caps(remote_only: bool, local_only: bool, name: str)` | §3.4 |
|
| 1139 |
+
| `call` | `call(name_at_version: str, body: str, stream: bool)` | §3.5 |
|
| 1140 |
+
| `log` | `log(follow: bool, level: str, component: str)` | §3.6 |
|
| 1141 |
+
| `trace` | `trace_recent(n: int, capability: str)` | §3.7 |
|
| 1142 |
+
| `doctor` | `doctor(check: str)` | §3.8 |
|
| 1143 |
+
| `export` | `export(out: str)` | §3.9 |
|
| 1144 |
+
| `erase` | `erase(keep_keys: bool, yes: bool)` | §3.10 |
|
| 1145 |
+
| `rag list` | `rag_list()` | §3.11 |
|
| 1146 |
+
| `rag ingest` | `rag_ingest(path: str, corpus: str)` | §3.11 |
|
| 1147 |
+
| `rag reindex` | `rag_reindex(corpus: str, embedding_model: str)` | §3.11 |
|
| 1148 |
+
| `invite create` | `invite_create(node_id: str, level: str, ttl: int)` | §3.12 |
|
| 1149 |
+
| `invite redeem` | `invite_redeem(text_or_path: str)` | §3.12 |
|
| 1150 |
+
| `version` | `version_cmd()` | §3.13 |
|
| 1151 |
+
|
| 1152 |
+
Exit codes — §6: `0` success, `1` generic error, `2` user abort / bad usage, `3` no running node, `4` auth, `5` capacity.
|
| 1153 |
+
|
| 1154 |
+
### `hearthnet/node.py` — §5
|
| 1155 |
+
|
| 1156 |
+
Single function — the canonical wiring:
|
| 1157 |
+
|
| 1158 |
+
```python
|
| 1159 |
+
async def start(config: Config) -> None:
|
| 1160 |
+
"""The 15-step composition. Do not deviate."""
|
| 1161 |
+
```
|
| 1162 |
+
|
| 1163 |
+
Sequence (each numbered in spec §5):
|
| 1164 |
+
1. observability configure
|
| 1165 |
+
2. identity load_or_generate
|
| 1166 |
+
3. community check / onboarding redirect
|
| 1167 |
+
4. event log + snapshot store + replay engine + community manifest
|
| 1168 |
+
5. blob store
|
| 1169 |
+
6. pinned-certs + transport client + bus
|
| 1170 |
+
7. peer registry + mdns/udp announcer + listener
|
| 1171 |
+
8. instantiate services (Embedding, Llm, Rag, File, Marketplace, Chat) and register with bus
|
| 1172 |
+
9. state bus + Detector
|
| 1173 |
+
10. http server
|
| 1174 |
+
11. UI app
|
| 1175 |
+
12. wire peer events → bus
|
| 1176 |
+
13. ManifestPublisher
|
| 1177 |
+
14. SyncClient periodic loop
|
| 1178 |
+
15. asyncio.gather(...) — block until shutdown
|
| 1179 |
+
|
| 1180 |
+
Auxiliary class declared inline in this module:
|
| 1181 |
+
|
| 1182 |
+
`ManifestPublisher`:
|
| 1183 |
+
- `__init__(kp: KeyPair, community_manifest_provider: Callable, bus: CapabilityBus, peer_registry: PeerRegistry, interval_seconds: int = MANIFEST_REPUBLISH_INTERVAL_SECONDS)`
|
| 1184 |
+
- `run() -> None` *(async)*
|
| 1185 |
+
- Publishes the freshly-built node manifest to mDNS + UDP every `interval_seconds`. Triggered also on `bus.registry` change events (capability added/removed).
|
| 1186 |
+
|
| 1187 |
+
`PeriodicTask` *(helper)*:
|
| 1188 |
+
- `__init__(fn: Callable[[], Awaitable], interval_seconds: int)`
|
| 1189 |
+
- `run() -> None` *(async)*
|
| 1190 |
+
|
| 1191 |
+
### `hearthnet/__main__.py`
|
| 1192 |
+
|
| 1193 |
+
Single line: `from hearthnet.cli import main; main()`
|
| 1194 |
+
|
| 1195 |
+
---
|
| 1196 |
+
|
| 1197 |
+
## 18. Cross-module symbol index (alphabetical)
|
| 1198 |
+
|
| 1199 |
+
For "where is `X` declared?"
|
| 1200 |
+
|
| 1201 |
+
| Symbol | Module | File |
|
| 1202 |
+
|--------|--------|------|
|
| 1203 |
+
| `AnthropicApiBackend` | M04 | `services/llm/backends/anthropic_api.py` |
|
| 1204 |
+
| `BackendModel` | M04 | `services/llm/backends/base.py` |
|
| 1205 |
+
| `BlobError` | M07 | `blobs/store.py` |
|
| 1206 |
+
| `BlobManifest` | M07 | `blobs/chunker.py` |
|
| 1207 |
+
| `BlobStore` | M07 | `blobs/store.py` |
|
| 1208 |
+
| `BusConfig` | X04 | `config.py` |
|
| 1209 |
+
| `BusError` | M03 | `bus/__init__.py` |
|
| 1210 |
+
| `CallError` | X01 | `transport/client.py` |
|
| 1211 |
+
| `CallTraceEvent` | M03 | `bus/trace.py` |
|
| 1212 |
+
| `CapabilityBus` | M03 | `bus/__init__.py` |
|
| 1213 |
+
| `CapabilityDescriptor` | M03 | `bus/capability.py` |
|
| 1214 |
+
| `CapabilityEntry` | M03 | `bus/capability.py` |
|
| 1215 |
+
| `CapabilitySpec` | M01 | `identity/manifest.py` |
|
| 1216 |
+
| `CapabilityToken` | M01 | `identity/tokens.py` |
|
| 1217 |
+
| `Category` | M06 | `services/marketplace/post.py` (Literal alias) |
|
| 1218 |
+
| `ChatConfig` | X04 | `config.py` |
|
| 1219 |
+
| `ChatMessage` | M10 | `services/chat/views.py` |
|
| 1220 |
+
| `ChatService` | M10 | `services/chat/service.py` |
|
| 1221 |
+
| `ChatView` | M10 | `services/chat/views.py` |
|
| 1222 |
+
| `CheckResult` | X03 | `observability/doctor.py` |
|
| 1223 |
+
| `Chunk` | M05 | `services/rag/chunker.py` |
|
| 1224 |
+
| `ChunkRef` | M07 | `blobs/chunker.py` |
|
| 1225 |
+
| `CommunityConfig` | X04 | `config.py` |
|
| 1226 |
+
| `CommunityManifest` | M01 | `identity/manifest.py` |
|
| 1227 |
+
| `CommunityMember` | M01 | `identity/manifest.py` |
|
| 1228 |
+
| `CommunityPolicy` | M01 | `identity/manifest.py` |
|
| 1229 |
+
| `Config` | X04 | `config.py` |
|
| 1230 |
+
| `ConfigError` | X04 | `config.py` |
|
| 1231 |
+
| `CorpusStore` | M05 | `services/rag/store.py` |
|
| 1232 |
+
| `DeliveryManager` | M10 | `services/chat/delivery.py` |
|
| 1233 |
+
| `Detector` | M09 | `emergency/detector.py` |
|
| 1234 |
+
| `Diff` | M03 | `bus/registry.py` |
|
| 1235 |
+
| `DiscoveryConfig` | X04 | `config.py` |
|
| 1236 |
+
| `DiscoveryError` | M02 | `discovery/__init__.py` |
|
| 1237 |
+
| `EmbeddingBackend` | M11 | `services/embedding/backends.py` |
|
| 1238 |
+
| `EmbeddingConfig` | X04 | `config.py` |
|
| 1239 |
+
| `EmbeddingService` | M11 | `services/embedding/service.py` |
|
| 1240 |
+
| `EmergencyConfig` | X04 | `config.py` |
|
| 1241 |
+
| `EmergencyState` | M09 | `emergency/state.py` |
|
| 1242 |
+
| `Endpoint` | M01 | `identity/manifest.py` |
|
| 1243 |
+
| `Event` | X02 | `events/types.py` |
|
| 1244 |
+
| `EventLog` | X02 | `events/log.py` |
|
| 1245 |
+
| `EventLogError` | X02 | `events/log.py` |
|
| 1246 |
+
| `EventType` | X02 | `events/types.py` |
|
| 1247 |
+
| `FileConfig` | X04 | `config.py` |
|
| 1248 |
+
| `FileService` | M07 | `services/file/service.py` |
|
| 1249 |
+
| `FlowControl` | X01 | `transport/backpressure.py` |
|
| 1250 |
+
| `Frame` | X01 | `transport/streams.py` |
|
| 1251 |
+
| `HardwareSpec` | M01 | `identity/manifest.py` |
|
| 1252 |
+
| `HeadsReport` | X02 | `events/sync.py` |
|
| 1253 |
+
| `HealthTracker` | M03 | `bus/health.py` |
|
| 1254 |
+
| `HfApiBackend` | M04 | `services/llm/backends/hf_api.py` |
|
| 1255 |
+
| `HttpClient` | X01 | `transport/client.py` |
|
| 1256 |
+
| `HttpServer` | X01 | `transport/server.py` |
|
| 1257 |
+
| `IdentityConfig` | X04 | `config.py` |
|
| 1258 |
+
| `IdentityError` | M01 | `identity/keys.py` |
|
| 1259 |
+
| `IngestPipeline` | M05 | `services/rag/ingest.py` |
|
| 1260 |
+
| `IngestResult` | M05 | `services/rag/ingest.py` |
|
| 1261 |
+
| `InviteBlob` | M13 | `ui/onboarding.py` |
|
| 1262 |
+
| `JsonFormatter` | X03 | `observability/logging.py` |
|
| 1263 |
+
| `KeyPair` | M01 | `identity/keys.py` |
|
| 1264 |
+
| `LamportClock` | X02 | `events/lamport.py` |
|
| 1265 |
+
| `LlamaCppBackend` | M04 | `services/llm/backends/llama_cpp.py` |
|
| 1266 |
+
| `LlmBackend` | M04 | `services/llm/backends/base.py` |
|
| 1267 |
+
| `LlmBackendConfig` | X04 | `config.py` |
|
| 1268 |
+
| `LlmConfig` | X04 | `config.py` |
|
| 1269 |
+
| `LlmService` | M04 | `services/llm/service.py` |
|
| 1270 |
+
| `LmStudioBackend` | M04 | `services/llm/backends/lmstudio.py` |
|
| 1271 |
+
| `Location` | M06 | `services/marketplace/post.py` |
|
| 1272 |
+
| `ManifestPublisher` | M12 | `node.py` |
|
| 1273 |
+
| `MarketConfig` | X04 | `config.py` |
|
| 1274 |
+
| `MarketplaceService` | M06 | `services/marketplace/service.py` |
|
| 1275 |
+
| `MarketplaceView` | M06 | `services/marketplace/views.py` |
|
| 1276 |
+
| `MaterialisedView` | X02 | `events/replay.py` (Protocol) |
|
| 1277 |
+
| `MdnsAnnouncer` | M02 | `discovery/mdns.py` |
|
| 1278 |
+
| `MdnsBrowser` | M02 | `discovery/mdns.py` |
|
| 1279 |
+
| `Mode` | M09 | `emergency/state.py` (Literal alias) |
|
| 1280 |
+
| `NemotronBackend` *(new)* | M04 | `services/llm/backends/nemotron.py` |
|
| 1281 |
+
| `NodeManifest` | M01 | `identity/manifest.py` |
|
| 1282 |
+
| `ObservabilityConfig` | X04 | `config.py` |
|
| 1283 |
+
| `OllamaBackend` | M04 | `services/llm/backends/ollama.py` |
|
| 1284 |
+
| `OnboardingError` | M13 | `ui/onboarding.py` |
|
| 1285 |
+
| `OpenBmbBackend` *(new)* | M04 | `services/llm/backends/openbmb.py` |
|
| 1286 |
+
| `ParamsPredicate` | M03 | `bus/capability.py` (type alias) |
|
| 1287 |
+
| `PeerEvent` | M02 | `discovery/peers.py` |
|
| 1288 |
+
| `PeerRecord` | M02 | `discovery/peers.py` |
|
| 1289 |
+
| `PeerRegistry` | M02 | `discovery/peers.py` |
|
| 1290 |
+
| `PeriodicTask` | M12 | `node.py` |
|
| 1291 |
+
| `PinnedCerts` | X01 | `transport/tls.py` |
|
| 1292 |
+
| `Post` | M06 | `services/marketplace/post.py` |
|
| 1293 |
+
| `Profile` | (types) | `hearthnet/types.py` (Literal alias) |
|
| 1294 |
+
| `PubSubServer` | X01 | `transport/server.py` |
|
| 1295 |
+
| `RagConfig` | X04 | `config.py` |
|
| 1296 |
+
| `RagService` | M05 | `services/rag/service.py` |
|
| 1297 |
+
| `RateCheck` | X01 | `transport/__init__.py` |
|
| 1298 |
+
| `RateLimiter` | X01 | `transport/__init__.py` |
|
| 1299 |
+
| `RateLimitedLogger` | X03 | `observability/logging.py` |
|
| 1300 |
+
| `Registry` | M03 | `bus/registry.py` |
|
| 1301 |
+
| `RegistryEvent` | M03 | `bus/registry.py` |
|
| 1302 |
+
| `ReplayEngine` | X02 | `events/replay.py` |
|
| 1303 |
+
| `RevokedEntry` | M01 | `identity/manifest.py` |
|
| 1304 |
+
| `RouteRequest` | M03 | `bus/capability.py` |
|
| 1305 |
+
| `Router` | M03 | `bus/router.py` |
|
| 1306 |
+
| `SchemaValidator` | M03 | `bus/schema.py` |
|
| 1307 |
+
| `ScoredChunk` | M05 | `services/rag/store.py` |
|
| 1308 |
+
| `SentenceTransformerBackend` | M11 | `services/embedding/backends.py` |
|
| 1309 |
+
| `Service` | M03 | `services/base.py` (Protocol) |
|
| 1310 |
+
| `Snapshot` | X02 | `events/snapshot.py` |
|
| 1311 |
+
| `SnapshotStore` | X02 | `events/snapshot.py` |
|
| 1312 |
+
| `Span` | X03 | `observability/tracing.py` |
|
| 1313 |
+
| `SseReader` | X01 | `transport/streams.py` |
|
| 1314 |
+
| `SseWriter` | X01 | `transport/streams.py` |
|
| 1315 |
+
| `StateBus` | M09 | `emergency/state.py` |
|
| 1316 |
+
| `SyncClient` | X02 | `events/sync.py` |
|
| 1317 |
+
| `SyncResult` | X02 | `events/sync.py` |
|
| 1318 |
+
| `SyncServer` | X02 | `events/sync.py` |
|
| 1319 |
+
| `Token` | M04 | `services/llm/backends/base.py` |
|
| 1320 |
+
| `TopologyComponent` | M08 | `ui/topology.py` |
|
| 1321 |
+
| `TopologySnapshot` | M03 | `bus/__init__.py` |
|
| 1322 |
+
| `Trace` | X03 | `observability/tracing.py` |
|
| 1323 |
+
| `TraceHook` | M03 | `bus/trace.py` |
|
| 1324 |
+
| `TrackioExporter` *(new)* | X03 | `observability/metrics.py` |
|
| 1325 |
+
| `TransferManager` | M07 | `blobs/transfer.py` |
|
| 1326 |
+
| `TransportConfig` | X04 | `config.py` |
|
| 1327 |
+
| `UdpAnnouncer` | M02 | `discovery/udp.py` |
|
| 1328 |
+
| `UdpListener` | M02 | `discovery/udp.py` |
|
| 1329 |
+
| `UiApp` | M08 | `ui/app.py` |
|
| 1330 |
+
| `UiConfig` | X04 | `config.py` |
|
| 1331 |
+
|
| 1332 |
+
---
|
| 1333 |
+
|
| 1334 |
+
## 19. Capability → handler index
|
| 1335 |
+
|
| 1336 |
+
For each capability in [CONTRACT §3.2](CAPABILITY_CONTRACT.md), where the handler lives:
|
| 1337 |
+
|
| 1338 |
+
| Capability | Service | Handler | Trust |
|
| 1339 |
+
|------------|---------|---------|-------|
|
| 1340 |
+
| `llm.chat@1.0` | M04 `LlmService` | `handle_chat` | member |
|
| 1341 |
+
| `llm.complete@1.0` | M04 `LlmService` | `handle_complete` | member |
|
| 1342 |
+
| `embed.text@1.0` | M11 `EmbeddingService` | `handle_embed_text` | member |
|
| 1343 |
+
| `rag.query@1.0` | M05 `RagService` | `handle_query` | member |
|
| 1344 |
+
| `rag.ingest@1.0` | M05 `RagService` | `handle_ingest` | trusted |
|
| 1345 |
+
| `rag.list_corpora@1.0` | M05 `RagService` | `handle_list_corpora` | member |
|
| 1346 |
+
| `file.read@1.0` | M07 `FileService` | `handle_read` | member |
|
| 1347 |
+
| `file.list@1.0` | M07 `FileService` | `handle_list` | member |
|
| 1348 |
+
| `file.advertise@1.0` | M07 `FileService` | `handle_advertise` | member |
|
| 1349 |
+
| `file.put@1.0` | M07 `FileService` | `handle_put` | trusted |
|
| 1350 |
+
| `market.list@1.0` | M06 `MarketplaceService` | `handle_list` | member |
|
| 1351 |
+
| `market.post@1.0` | M06 `MarketplaceService` | `handle_post` | member |
|
| 1352 |
+
| `market.expire@1.0` | M06 `MarketplaceService` | `handle_expire` | member |
|
| 1353 |
+
| `market.search@1.0` | M06 `MarketplaceService` | `handle_search` | member |
|
| 1354 |
+
| `chat.send@1.0` | M10 `ChatService` | `handle_send` | member |
|
| 1355 |
+
| `chat.history@1.0` | M10 `ChatService` | `handle_history` | self |
|
| 1356 |
+
| `community.invite@1.0` | M13 (handler via bus from `make_invite`) | n/a | member with `can_invite` |
|
| 1357 |
+
| `community.revoke@1.0` | M13 / M01 helper | n/a | 3 trusted signatures |
|
| 1358 |
+
|
| 1359 |
+
---
|
| 1360 |
+
|
| 1361 |
+
## 20. Event-type → producer/consumer index
|
| 1362 |
+
|
| 1363 |
+
For each [CONTRACT §7.2](CAPABILITY_CONTRACT.md) event type:
|
| 1364 |
+
|
| 1365 |
+
| Event type | Produced by | View(s) consuming |
|
| 1366 |
+
|------------|-------------|--------------------|
|
| 1367 |
+
| `community.created` | M13 `create_community` | M01 community manifest builder |
|
| 1368 |
+
| `community.member.invited` | M13 `make_invite` | M01 |
|
| 1369 |
+
| `community.member.joined` | M13 `redeem_invite` | M01 |
|
| 1370 |
+
| `community.member.revoked` | M01 helper / `community.revoke` handler | M01 |
|
| 1371 |
+
| `community.member.promoted` / `.demoted` | M01 helpers | M01 |
|
| 1372 |
+
| `community.policy.updated` | M01 (root key only) | M01 |
|
| 1373 |
+
| `node.manifest.updated` | M12 `ManifestPublisher` | optional audit views |
|
| 1374 |
+
| `market.post.created` | M06 `handle_post` | M06 `MarketplaceView` |
|
| 1375 |
+
| `market.post.updated` | M06 (author only) | M06 `MarketplaceView` |
|
| 1376 |
+
| `market.post.expired` | M06 (author or sweeper) | M06 `MarketplaceView` |
|
| 1377 |
+
| `chat.message.sent` | M10 `handle_send` | M10 `ChatView` |
|
| 1378 |
+
| `chat.message.delivered` | M10 `DeliveryManager` | M10 `ChatView` |
|
| 1379 |
+
| `chat.message.read` | M10 (UI) | M10 `ChatView` |
|
| 1380 |
+
| `file.cid.advertised` | M07 `TransferManager.advertise` | local source index in `FileService` |
|
| 1381 |
+
| `file.cid.unpinned` | M07 `BlobStore.unpin` | local source index |
|
| 1382 |
+
| `rag.document.ingested` | M05 `IngestPipeline` | M05 (replicas may pre-fetch) |
|
| 1383 |
+
| `federation.peer.added` / `.removed` | reserved (Phase 2) | — |
|
| 1384 |
+
|
| 1385 |
+
---
|
| 1386 |
+
|
| 1387 |
+
## 21. Standard params for each capability descriptor
|
| 1388 |
+
|
| 1389 |
+
Used by [CONTRACT §6.1](CAPABILITY_CONTRACT.md) node manifest embedding and by the bus's params-compatibility check.
|
| 1390 |
+
|
| 1391 |
+
| Capability | `params` keys |
|
| 1392 |
+
|------------|---------------|
|
| 1393 |
+
| `llm.chat` | `model`, `quant`, `ctx`, `backend`, `modalities`, optionally `requires_internet` |
|
| 1394 |
+
| `llm.complete` | same as `llm.chat` |
|
| 1395 |
+
| `embed.text` | `model` |
|
| 1396 |
+
| `rag.query` | `corpus`, `embedding_model`, `k_max` |
|
| 1397 |
+
| `rag.ingest` | `corpora_available` (list) |
|
| 1398 |
+
| `rag.list_corpora` | `{}` |
|
| 1399 |
+
| `file.read` | `{}` |
|
| 1400 |
+
| `file.list` | `{}` |
|
| 1401 |
+
| `file.advertise` | `{}` |
|
| 1402 |
+
| `file.put` | `{}` |
|
| 1403 |
+
| `market.*` | `{}` |
|
| 1404 |
+
| `chat.send` | `{}` |
|
| 1405 |
+
| `chat.history` | `{}` |
|
| 1406 |
+
|
| 1407 |
+
---
|
| 1408 |
+
|
| 1409 |
+
## 22. Implementation checklist (one row per implementable unit)
|
| 1410 |
+
|
| 1411 |
+
Tick these off as you build. Order: dependency-correct.
|
| 1412 |
+
|
| 1413 |
+
### X04 Config (~6 dataclasses, ~5 functions, ~1 exception)
|
| 1414 |
+
- [ ] `IdentityConfig`, `CommunityConfig`, `TransportConfig`, `DiscoveryConfig`, `BusConfig`
|
| 1415 |
+
- [ ] `LlmBackendConfig`, `LlmConfig`
|
| 1416 |
+
- [ ] `EmbeddingConfig`, `RagConfig`, `FileConfig`, `MarketConfig`, `ChatConfig`
|
| 1417 |
+
- [ ] `EmergencyConfig`, `UiConfig`, `ObservabilityConfig` *(incl. trackio_project/trackio_space)*
|
| 1418 |
+
- [ ] `Config` (aggregate)
|
| 1419 |
+
- [ ] `load`, `default_config`, `save`, `resolve_paths`, `validate`
|
| 1420 |
+
- [ ] `ConfigError`
|
| 1421 |
+
- [ ] `constants.py` with all 31 named constants
|
| 1422 |
+
|
| 1423 |
+
### X03 Observability (~6 classes, ~14 functions)
|
| 1424 |
+
- [ ] `configure`, `get_logger`, `JsonFormatter`, `RateLimitedLogger`
|
| 1425 |
+
- [ ] `configure` (metrics), `counter`, `histogram`, `gauge`, `disabled`
|
| 1426 |
+
- [ ] All 14 standard metrics pre-registered
|
| 1427 |
+
- [ ] `TrackioExporter` *(new, optional)*
|
| 1428 |
+
- [ ] `Trace`, `Span`, `new_trace`, `current_trace`, `attach`, `detach`, `span`, `get_recent`
|
| 1429 |
+
- [ ] `CheckResult`, `register`, `run_all`, `run_one`
|
| 1430 |
+
- [ ] 12 standard checks registered
|
| 1431 |
+
|
| 1432 |
+
### X02 Events (~7 classes, ~3 functions)
|
| 1433 |
+
- [ ] `EventType`, `Event`
|
| 1434 |
+
- [ ] `LamportClock`
|
| 1435 |
+
- [ ] `EventLog`, `EventLogError`
|
| 1436 |
+
- [ ] `MaterialisedView` (Protocol), `ReplayEngine`
|
| 1437 |
+
- [ ] `Snapshot`, `SnapshotStore`, `build_snapshot`, `restore_from_snapshot`
|
| 1438 |
+
- [ ] `HeadsReport`, `SyncResult`, `SyncClient`, `SyncServer`
|
| 1439 |
+
|
| 1440 |
+
### X01 Transport (~9 classes, ~1 exception)
|
| 1441 |
+
- [ ] `HttpServer` + 10 endpoints
|
| 1442 |
+
- [ ] `HttpClient`, `CallError`
|
| 1443 |
+
- [ ] `Frame`, `SseWriter`, `SseReader`
|
| 1444 |
+
- [ ] `FlowControl`
|
| 1445 |
+
- [ ] `PinnedCerts`
|
| 1446 |
+
- [ ] `RateCheck`, `RateLimiter`
|
| 1447 |
+
- [ ] `PubSubServer`
|
| 1448 |
+
|
| 1449 |
+
### M01 Identity (~12 classes, ~16 functions, ~1 exception)
|
| 1450 |
+
- [ ] `KeyPair`, all keys.py functions, `IdentityError`
|
| 1451 |
+
- [ ] `Endpoint`, `HardwareSpec`, `CapabilitySpec`, `NodeManifest`
|
| 1452 |
+
- [ ] `CommunityPolicy`, `CommunityMember`, `RevokedEntry`, `CommunityManifest`
|
| 1453 |
+
- [ ] All builder/parser/verifier functions
|
| 1454 |
+
- [ ] `CapabilityToken` stub
|
| 1455 |
+
|
| 1456 |
+
### M02 Discovery (~6 classes)
|
| 1457 |
+
- [ ] `PeerRecord`, `PeerEvent`, `PeerRegistry`
|
| 1458 |
+
- [ ] `MdnsAnnouncer`, `MdnsBrowser`
|
| 1459 |
+
- [ ] `UdpAnnouncer`, `UdpListener`
|
| 1460 |
+
- [ ] `DiscoveryError`
|
| 1461 |
+
|
| 1462 |
+
### M03 Capability Bus (~10 classes, ~1 function, ~1 exception) — CRITICAL
|
| 1463 |
+
- [ ] `CapabilityDescriptor`, `CapabilityEntry`, `RouteRequest`
|
| 1464 |
+
- [ ] `Diff`, `RegistryEvent`, `Registry`
|
| 1465 |
+
- [ ] `HealthTracker`
|
| 1466 |
+
- [ ] `SchemaValidator`, `compute_schema_hash`
|
| 1467 |
+
- [ ] `Router` (with scoring algorithm from M03 §5.4)
|
| 1468 |
+
- [ ] `CallTraceEvent`, `TraceHook`
|
| 1469 |
+
- [ ] `TopologySnapshot`, `CapabilityBus` (facade), `BusError`
|
| 1470 |
+
- [ ] `Service` Protocol in `services/base.py`
|
| 1471 |
+
|
| 1472 |
+
### M11 Embedding (~3 classes)
|
| 1473 |
+
- [ ] `EmbeddingBackend` Protocol
|
| 1474 |
+
- [ ] `SentenceTransformerBackend`
|
| 1475 |
+
- [ ] `EmbeddingService` + `handle_embed_text` + params predicate
|
| 1476 |
+
|
| 1477 |
+
### M04 LLM (~6 backends + 3 base classes + 2 base functions + 1 service)
|
| 1478 |
+
- [ ] `Token`, `ChatResult`, `BackendModel`, `LlmBackend` Protocol
|
| 1479 |
+
- [ ] `LlamaCppBackend`
|
| 1480 |
+
- [ ] `OllamaBackend`
|
| 1481 |
+
- [ ] `LmStudioBackend`
|
| 1482 |
+
- [ ] `HfApiBackend`
|
| 1483 |
+
- [ ] `AnthropicApiBackend`
|
| 1484 |
+
- [ ] **`NemotronBackend`** *(new — NVIDIA NIM / locally-hosted)*
|
| 1485 |
+
- [ ] **`OpenBmbBackend`** *(new — MiniCPM via vLLM/llama.cpp serve/SGLang)*
|
| 1486 |
+
- [ ] `count_tokens_approx`, `model_family`
|
| 1487 |
+
- [ ] `LlmService` + `handle_chat` + `handle_complete` + params predicate
|
| 1488 |
+
|
| 1489 |
+
### M05 RAG (~5 classes, ~4 functions)
|
| 1490 |
+
- [ ] `Chunk`, `chunk_text`, `chunk_pdf`
|
| 1491 |
+
- [ ] `ScoredChunk`, `CorpusStore`, `list_corpora`, `corpus_info`
|
| 1492 |
+
- [ ] `IngestResult`, `IngestPipeline`
|
| 1493 |
+
- [ ] `RagService` + 3 handlers + params predicate
|
| 1494 |
+
|
| 1495 |
+
### M07 File & Blobs (~4 classes, ~5 functions, ~1 exception)
|
| 1496 |
+
- [ ] `ChunkRef`, `BlobManifest`
|
| 1497 |
+
- [ ] All chunker.py functions
|
| 1498 |
+
- [ ] `BlobStore`, `BlobError`
|
| 1499 |
+
- [ ] `TransferManager`
|
| 1500 |
+
- [ ] `FileService` + 4 handlers
|
| 1501 |
+
|
| 1502 |
+
### M06 Marketplace (~3 classes)
|
| 1503 |
+
- [ ] `Location`, `Post`
|
| 1504 |
+
- [ ] `MarketplaceView`
|
| 1505 |
+
- [ ] `MarketplaceService` + 4 handlers + sweeper
|
| 1506 |
+
|
| 1507 |
+
### M10 Chat (~3 classes)
|
| 1508 |
+
- [ ] `ChatMessage`, `ChatView`
|
| 1509 |
+
- [ ] `DeliveryManager`
|
| 1510 |
+
- [ ] `ChatService` + 2 handlers
|
| 1511 |
+
|
| 1512 |
+
### M09 Emergency (~3 classes)
|
| 1513 |
+
- [ ] `EmergencyState`, `StateBus`
|
| 1514 |
+
- [ ] `Detector` (state machine + probe loop)
|
| 1515 |
+
|
| 1516 |
+
### M08 UI (~2 classes + ~6 tab builders + theme + mobile assets)
|
| 1517 |
+
- [ ] `UiApp`, `build_ui`
|
| 1518 |
+
- [ ] `TopologyComponent`
|
| 1519 |
+
- [ ] `hearthnet_theme`, `emergency_theme`
|
| 1520 |
+
- [ ] 6 tab builders
|
| 1521 |
+
- [ ] Mobile static assets
|
| 1522 |
+
|
| 1523 |
+
### M13 Onboarding (~1 class, ~7 functions, ~1 exception)
|
| 1524 |
+
- [ ] `InviteBlob`
|
| 1525 |
+
- [ ] All onboarding functions
|
| 1526 |
+
- [ ] `build_onboarding`
|
| 1527 |
+
- [ ] `OnboardingError`
|
| 1528 |
+
|
| 1529 |
+
### M12 CLI & Orchestrator (~17 commands + ~2 helper classes + 1 function)
|
| 1530 |
+
- [ ] All 17 Click subcommands
|
| 1531 |
+
- [ ] `ManifestPublisher`, `PeriodicTask`
|
| 1532 |
+
- [ ] `node.start()` — the 15-step composition
|
| 1533 |
+
|
| 1534 |
+
---
|
| 1535 |
+
|
| 1536 |
+
## 23. Notes on the trackio integration
|
| 1537 |
+
|
| 1538 |
+
[Trackio](https://github.com/huggingface/trackio) is HuggingFace's local-first experiment tracker built on Gradio. Optional in HearthNet; enable by setting `config.observability.trackio_project`.
|
| 1539 |
+
|
| 1540 |
+
Integration points:
|
| 1541 |
+
|
| 1542 |
+
1. **Activated by config.** `TrackioExporter` is constructed only if `trackio_project` is set. Otherwise the class is unused; HearthNet runs Prometheus-only.
|
| 1543 |
+
|
| 1544 |
+
2. **Bridged from TraceHook.** `M03 §3.6` `TraceHook.on_call_end` checks for an active exporter and forwards. No service code calls trackio directly.
|
| 1545 |
+
|
| 1546 |
+
3. **Optional HF Spaces sync.** If `trackio_space` is set, runs mirror to the named Space — handy for sharing demo telemetry. Off by default; the demo on Christof's machine logs locally.
|
| 1547 |
+
|
| 1548 |
+
4. **What gets logged.** Each LLM call is one step with: `latency_ms`, `tokens_in`, `tokens_out`, `model`, `backend`, `result`. Topology snapshots logged every 60s with mesh size, online state, capability counts. Marketplace post counts and chat throughput as gauges.
|
| 1549 |
+
|
| 1550 |
+
5. **Why this fits HearthNet.** Trackio is local-first (matches HearthNet's ethos), Gradio-native (matches the existing UI stack), and gives Christof a dashboard he already knows how to extend without adding Prometheus + Grafana.
|
| 1551 |
+
|
| 1552 |
+
---
|
| 1553 |
+
|
| 1554 |
+
## 24. Notes on the Nemotron and OpenBMB backends
|
| 1555 |
+
|
| 1556 |
+
Both register exactly like existing backends: as `LlmBackend` implementations producing `BackendModel` entries that the service enumerates as `(backend, model)` capability instances.
|
| 1557 |
+
|
| 1558 |
+
### `NemotronBackend`
|
| 1559 |
+
|
| 1560 |
+
NVIDIA's Nemotron family (Llama-3.1-Nemotron-70B, Nemotron-mini, Nemotron-4-340B-instruct). Two modes:
|
| 1561 |
+
|
| 1562 |
+
- **Cloud (default):** `https://integrate.api.nvidia.com/v1`, OpenAI-compatible. `requires_internet=True`. Free tier exists; bring an `NVIDIA_API_KEY`. M09 will deregister this backend automatically when offline.
|
| 1563 |
+
- **Local (`local=True`):** point at a self-hosted NIM endpoint or vLLM-served Nemotron model. `requires_internet=False`.
|
| 1564 |
+
|
| 1565 |
+
Models declared by `models: list[BackendModel]` at construction time. Use these typical entries:
|
| 1566 |
+
|
| 1567 |
+
```python
|
| 1568 |
+
BackendModel("nvidia/llama-3.1-nemotron-70b-instruct", quant="api", ctx_max=128000, modalities=["text"], requires_internet=True)
|
| 1569 |
+
BackendModel("nvidia/nemotron-mini-4b-instruct", quant="api", ctx_max=4096, modalities=["text"], requires_internet=True)
|
| 1570 |
+
```
|
| 1571 |
+
|
| 1572 |
+
### `OpenBmbBackend`
|
| 1573 |
+
|
| 1574 |
+
OpenBMB's MiniCPM family — Christof's primary local-AI-workbench target. Typically served via vLLM, SGLang, or llama.cpp's HTTP server on `http://localhost:8000` (or wherever the workbench binds). OpenAI-compatible HTTP. `requires_internet=False` (always local).
|
| 1575 |
+
|
| 1576 |
+
Models declared:
|
| 1577 |
+
|
| 1578 |
+
```python
|
| 1579 |
+
BackendModel("openbmb/MiniCPM4-8B", quant="fp16", ctx_max=32768, modalities=["text"], requires_internet=False)
|
| 1580 |
+
BackendModel("openbmb/MiniCPM-V-2_6", quant="fp16", ctx_max=8192, modalities=["text","vision"], requires_internet=False)
|
| 1581 |
+
```
|
| 1582 |
+
|
| 1583 |
+
Vision-capable MiniCPM-V variant is reserved for Phase 2 when [CONTRACT §12 open question 1](CAPABILITY_CONTRACT.md) lifts; vision messages stay text-only in MVP.
|
| 1584 |
+
|
| 1585 |
+
### Config example
|
| 1586 |
+
|
| 1587 |
+
```toml
|
| 1588 |
+
[[llm.backends]]
|
| 1589 |
+
name = "openbmb"
|
| 1590 |
+
url = "http://localhost:8000"
|
| 1591 |
+
model = "openbmb/MiniCPM4-8B"
|
| 1592 |
+
|
| 1593 |
+
[[llm.backends]]
|
| 1594 |
+
name = "nemotron"
|
| 1595 |
+
url = "https://integrate.api.nvidia.com/v1"
|
| 1596 |
+
model = "nvidia/llama-3.1-nemotron-70b-instruct"
|
| 1597 |
+
api_key_env = "NVIDIA_API_KEY"
|
| 1598 |
+
|
| 1599 |
+
[[llm.backends]]
|
| 1600 |
+
name = "lmstudio"
|
| 1601 |
+
url = "http://192.168.188.25:1234"
|
| 1602 |
+
model = "qwen2.5-7b-instruct"
|
| 1603 |
+
```
|
| 1604 |
+
|
| 1605 |
+
Three backends, four models if MiniCPM-V is later added → eight capability entries on the bus (two each for `llm.chat` and `llm.complete` × four models). The router picks among them at call time.
|
| 1606 |
+
|
| 1607 |
+
---
|
| 1608 |
+
|
| 1609 |
+
## 25. Coherence guarantees enforced by this reference
|
| 1610 |
+
|
| 1611 |
+
If you implement strictly against this document, the following hold automatically:
|
| 1612 |
+
|
| 1613 |
+
- **No symbol name appears in two different modules** — see §18.
|
| 1614 |
+
- **Every capability has exactly one handler** — see §19.
|
| 1615 |
+
- **Every event type has at least one producer** — see §20.
|
| 1616 |
+
- **Every constant is defined in `constants.py` and nowhere else** — see §0.2.
|
| 1617 |
+
- **The 15-step orchestration produces a runnable node** — see §17 `node.py`.
|
| 1618 |
+
- **Cross-references resolve** — every `M0N`/`X0N` link points at an existing spec; every spec section number used here exists.
|
| 1619 |
+
|
| 1620 |
+
If you find a contradiction between this document and a spec, the spec wins by default — but file the discrepancy. The most common drift will be in field names of capability `params` (caught by the params predicate at registration time).
|
| 1621 |
+
|
| 1622 |
+
---
|
| 1623 |
+
|
| 1624 |
+
## 26. What is intentionally NOT in this document
|
| 1625 |
+
|
| 1626 |
+
- **Test code** — see the `tests/` section in each spec. Implement after the production code compiles.
|
| 1627 |
+
- **Service-internal helpers** that are pure implementation detail (private functions inside one file with leading underscore). Add as you need them.
|
| 1628 |
+
- **Phase 2/3 modules** — `federation.*`, `ocr.*`, `tts.*`, `stt.*`, `trans.*`, `img.*`, `chat.thread.*`, `chat.forward.*` are mentioned in specs but have no MVP symbols.
|
| 1629 |
+
- **Vendor-specific tuning** — llama.cpp `n_threads`, vLLM tensor parallel, Nemotron prompt prefixes. Decide per backend at integration time.
|
| 1630 |
+
|
| 1631 |
+
---
|
| 1632 |
+
|
| 1633 |
+
*End of HearthNet Implementation Reference.*
|
| 1634 |
+
*Spec set version: v1.0 · this document touched: 2026-06-09.*
|
|
|
|
|
|
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""M31 — Civil Defense package (experimental, Phase 3)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from hearthnet.civdef.service import Alert, AuditChain, CivilDefenseService, RoleCertificate
|
| 5 |
+
|
| 6 |
+
__all__ = ["Alert", "AuditChain", "CivilDefenseService", "RoleCertificate"]
|
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""M31 — Civil Defense (NRW Bevölkerungsschutz pilot, experimental Phase 3).
|
| 2 |
+
|
| 3 |
+
Bridges HearthNet with THW/DRK/Feuerwehr/KatS role structures.
|
| 4 |
+
Produces tamper-evident audit trails for incident coordination.
|
| 5 |
+
Gated by config.research.civil_defense = True.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import hashlib
|
| 10 |
+
import json
|
| 11 |
+
import time
|
| 12 |
+
import uuid
|
| 13 |
+
from dataclasses import dataclass, field
|
| 14 |
+
from typing import Any
|
| 15 |
+
|
| 16 |
+
# NRW role taxonomy
|
| 17 |
+
NRW_ROLES = {
|
| 18 |
+
"thw_helferin": "THW Helferin/Helfer",
|
| 19 |
+
"thw_gruppenfuehrer": "THW Gruppenführer",
|
| 20 |
+
"drk_ersthelfer": "DRK Ersthelfer",
|
| 21 |
+
"drk_sanitaeter": "DRK Sanitäter",
|
| 22 |
+
"feuerwehr_angehoeriger": "Feuerwehr-Angehöriger",
|
| 23 |
+
"feuerwehr_fuehrungskraft": "Feuerwehr-Führungskraft",
|
| 24 |
+
"kats_koordinator": "KatS-Koordinator",
|
| 25 |
+
"kats_leiterin": "KatS-Leiterin",
|
| 26 |
+
"bevoelkerungsschutz_beauftragte": "Bevölkerungsschutzbeauftragte(r)",
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass(frozen=True)
|
| 31 |
+
class AlertSeverity:
|
| 32 |
+
INFORMATION = "information"
|
| 33 |
+
WARNING = "warning"
|
| 34 |
+
ALERT = "alert"
|
| 35 |
+
EMERGENCY = "emergency"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass(frozen=True)
|
| 39 |
+
class RoleCertificate:
|
| 40 |
+
"""A role certificate issued by an authority for a community member."""
|
| 41 |
+
cert_id: str
|
| 42 |
+
role_key: str # key from NRW_ROLES
|
| 43 |
+
role_label: str
|
| 44 |
+
holder_node_id: str
|
| 45 |
+
issuer_node_id: str
|
| 46 |
+
community_id: str
|
| 47 |
+
region: str = "NRW"
|
| 48 |
+
issued_at: float = field(default_factory=time.time)
|
| 49 |
+
expires_at: float | None = None
|
| 50 |
+
issuer_signature: bytes = b""
|
| 51 |
+
|
| 52 |
+
def is_expired(self, now: float | None = None) -> bool:
|
| 53 |
+
if self.expires_at is None:
|
| 54 |
+
return False
|
| 55 |
+
return (now or time.time()) > self.expires_at
|
| 56 |
+
|
| 57 |
+
def role_name(self) -> str:
|
| 58 |
+
return NRW_ROLES.get(self.role_key, self.role_label)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@dataclass(frozen=True)
|
| 62 |
+
class Alert:
|
| 63 |
+
"""A civil-defense alert with full provenance."""
|
| 64 |
+
alert_id: str
|
| 65 |
+
severity: str # AlertSeverity constant
|
| 66 |
+
title: str
|
| 67 |
+
body: str
|
| 68 |
+
area_description: str # e.g. "Issum, Kreis Kleve, NRW"
|
| 69 |
+
issuer_node_id: str
|
| 70 |
+
issuer_role_cert_id: str | None
|
| 71 |
+
community_id: str
|
| 72 |
+
event_log_id: str | None = None # optional backlink to event log entry
|
| 73 |
+
issued_at: float = field(default_factory=time.time)
|
| 74 |
+
expires_at: float | None = None
|
| 75 |
+
issuer_signature: bytes = b""
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class AuditChain:
|
| 79 |
+
"""Tamper-evident append-only audit log for civil-defense operations.
|
| 80 |
+
|
| 81 |
+
Each entry is a JSON-serialised dict with a backlink hash for chain integrity.
|
| 82 |
+
For production use, entries should be stored in the event log (X02) with
|
| 83 |
+
Ed25519 signatures to satisfy legal audit retention requirements.
|
| 84 |
+
"""
|
| 85 |
+
|
| 86 |
+
def __init__(self) -> None:
|
| 87 |
+
self._entries: list[dict] = []
|
| 88 |
+
self._head_hash: str = "0" * 64
|
| 89 |
+
|
| 90 |
+
def _hash_entry(self, entry: dict) -> str:
|
| 91 |
+
serialised = json.dumps(entry, sort_keys=True, ensure_ascii=True)
|
| 92 |
+
return hashlib.sha256(serialised.encode()).hexdigest()
|
| 93 |
+
|
| 94 |
+
def append(self, entry_type: str, actor_node_id: str, payload: dict[str, Any]) -> str:
|
| 95 |
+
entry = {
|
| 96 |
+
"entry_id": str(uuid.uuid4()),
|
| 97 |
+
"entry_type": entry_type,
|
| 98 |
+
"actor": actor_node_id,
|
| 99 |
+
"payload": payload,
|
| 100 |
+
"timestamp": time.time(),
|
| 101 |
+
"prev_hash": self._head_hash,
|
| 102 |
+
}
|
| 103 |
+
entry_hash = self._hash_entry(entry)
|
| 104 |
+
entry["hash"] = entry_hash
|
| 105 |
+
self._entries.append(entry)
|
| 106 |
+
self._head_hash = entry_hash
|
| 107 |
+
return entry_hash
|
| 108 |
+
|
| 109 |
+
def verify_integrity(self) -> bool:
|
| 110 |
+
"""Walk the chain and verify all backlinks."""
|
| 111 |
+
prev = "0" * 64
|
| 112 |
+
for entry in self._entries:
|
| 113 |
+
if entry.get("prev_hash") != prev:
|
| 114 |
+
return False
|
| 115 |
+
expected_hash = entry["hash"]
|
| 116 |
+
entry_copy = {k: v for k, v in entry.items() if k != "hash"}
|
| 117 |
+
if self._hash_entry(entry_copy) != expected_hash:
|
| 118 |
+
return False
|
| 119 |
+
prev = expected_hash
|
| 120 |
+
return True
|
| 121 |
+
|
| 122 |
+
def export(self) -> list[dict]:
|
| 123 |
+
return list(self._entries)
|
| 124 |
+
|
| 125 |
+
def length(self) -> int:
|
| 126 |
+
return len(self._entries)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class CivilDefenseService:
|
| 130 |
+
"""Civil-defense pilot service for NRW.
|
| 131 |
+
|
| 132 |
+
Registers capabilities:
|
| 133 |
+
civdef.alert.issue@1.0 — publish a signed alert
|
| 134 |
+
civdef.alert.list@1.0 — list active alerts
|
| 135 |
+
civdef.cert.verify@1.0 — verify a role certificate
|
| 136 |
+
civdef.audit.export@1.0 — export tamper-evident audit chain
|
| 137 |
+
|
| 138 |
+
Only active when config.research.civil_defense = True.
|
| 139 |
+
"""
|
| 140 |
+
|
| 141 |
+
def __init__(self, keypair=None, bus=None) -> None:
|
| 142 |
+
self._keypair = keypair
|
| 143 |
+
self._bus = bus
|
| 144 |
+
self._alerts: dict[str, Alert] = {}
|
| 145 |
+
self._certs: dict[str, RoleCertificate] = {}
|
| 146 |
+
self._audit = AuditChain()
|
| 147 |
+
|
| 148 |
+
def issue_alert(
|
| 149 |
+
self,
|
| 150 |
+
severity: str,
|
| 151 |
+
title: str,
|
| 152 |
+
body: str,
|
| 153 |
+
area: str,
|
| 154 |
+
role_cert_id: str | None = None,
|
| 155 |
+
community_id: str = "",
|
| 156 |
+
expires_in_hours: float | None = 24.0,
|
| 157 |
+
) -> Alert:
|
| 158 |
+
node_id = getattr(self._keypair, "node_id_short", "unknown")
|
| 159 |
+
alert = Alert(
|
| 160 |
+
alert_id=str(uuid.uuid4()),
|
| 161 |
+
severity=severity,
|
| 162 |
+
title=title,
|
| 163 |
+
body=body,
|
| 164 |
+
area_description=area,
|
| 165 |
+
issuer_node_id=node_id,
|
| 166 |
+
issuer_role_cert_id=role_cert_id,
|
| 167 |
+
community_id=community_id,
|
| 168 |
+
expires_at=time.time() + expires_in_hours * 3600 if expires_in_hours else None,
|
| 169 |
+
)
|
| 170 |
+
self._alerts[alert.alert_id] = alert
|
| 171 |
+
self._audit.append("alert.issued", node_id, {
|
| 172 |
+
"alert_id": alert.alert_id,
|
| 173 |
+
"severity": alert.severity,
|
| 174 |
+
"title": alert.title,
|
| 175 |
+
})
|
| 176 |
+
return alert
|
| 177 |
+
|
| 178 |
+
def list_active_alerts(self, now: float | None = None) -> list[Alert]:
|
| 179 |
+
now = now or time.time()
|
| 180 |
+
return [
|
| 181 |
+
a for a in self._alerts.values()
|
| 182 |
+
if a.expires_at is None or a.expires_at > now
|
| 183 |
+
]
|
| 184 |
+
|
| 185 |
+
def register_cert(self, cert: RoleCertificate) -> None:
|
| 186 |
+
self._certs[cert.cert_id] = cert
|
| 187 |
+
self._audit.append("cert.registered", cert.issuer_node_id, {
|
| 188 |
+
"cert_id": cert.cert_id, "role": cert.role_key, "holder": cert.holder_node_id
|
| 189 |
+
})
|
| 190 |
+
|
| 191 |
+
def verify_cert(self, cert_id: str) -> dict:
|
| 192 |
+
cert = self._certs.get(cert_id)
|
| 193 |
+
if cert is None:
|
| 194 |
+
return {"valid": False, "reason": "cert_not_found"}
|
| 195 |
+
if cert.is_expired():
|
| 196 |
+
return {"valid": False, "reason": "cert_expired", "cert_id": cert_id}
|
| 197 |
+
return {
|
| 198 |
+
"valid": True,
|
| 199 |
+
"role": cert.role_name(),
|
| 200 |
+
"holder": cert.holder_node_id,
|
| 201 |
+
"expires_at": cert.expires_at,
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
def export_audit(self) -> dict:
|
| 205 |
+
return {
|
| 206 |
+
"entries": self._audit.export(),
|
| 207 |
+
"chain_valid": self._audit.verify_integrity(),
|
| 208 |
+
"length": self._audit.length(),
|
| 209 |
+
}
|
|
@@ -140,6 +140,19 @@ class ObservabilityConfig:
|
|
| 140 |
otlp_endpoint: str | None = None
|
| 141 |
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
@dataclass(frozen=True)
|
| 144 |
class Config:
|
| 145 |
identity: IdentityConfig = field(default_factory=IdentityConfig)
|
|
@@ -156,6 +169,7 @@ class Config:
|
|
| 156 |
emergency: EmergencyConfig = field(default_factory=EmergencyConfig)
|
| 157 |
ui: UiConfig = field(default_factory=UiConfig)
|
| 158 |
observability: ObservabilityConfig = field(default_factory=ObservabilityConfig)
|
|
|
|
| 159 |
|
| 160 |
|
| 161 |
# ── ConfigError ───────────────────────────────────────────────────────────────
|
|
|
|
| 140 |
otlp_endpoint: str | None = None
|
| 141 |
|
| 142 |
|
| 143 |
+
@dataclass(frozen=True)
|
| 144 |
+
class ResearchConfig:
|
| 145 |
+
"""Phase 3 experimental feature flags. All default False."""
|
| 146 |
+
enable: bool = False
|
| 147 |
+
distributed_inference: bool = False
|
| 148 |
+
moe_routing: bool = False
|
| 149 |
+
federated_learning: bool = False
|
| 150 |
+
lora_beacons: bool = False
|
| 151 |
+
evidence_graph: bool = False
|
| 152 |
+
civil_defense: bool = False
|
| 153 |
+
conformance_suite: bool = False
|
| 154 |
+
|
| 155 |
+
|
| 156 |
@dataclass(frozen=True)
|
| 157 |
class Config:
|
| 158 |
identity: IdentityConfig = field(default_factory=IdentityConfig)
|
|
|
|
| 169 |
emergency: EmergencyConfig = field(default_factory=EmergencyConfig)
|
| 170 |
ui: UiConfig = field(default_factory=UiConfig)
|
| 171 |
observability: ObservabilityConfig = field(default_factory=ObservabilityConfig)
|
| 172 |
+
research: ResearchConfig = field(default_factory=ResearchConfig)
|
| 173 |
|
| 174 |
|
| 175 |
# ── ConfigError ───────────────────────────────────────────────────────────────
|
|
@@ -58,6 +58,10 @@ INVITE_DEFAULT_TTL_SECONDS: int = 86400 # 24 h
|
|
| 58 |
|
| 59 |
# ── RAG / Embedding ──────────────────────────────────────────────────────────
|
| 60 |
RAG_DEFAULT_CHUNK_SIZE_TOKENS: int = 512
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
EMBED_MAX_TEXTS: int = 256
|
| 62 |
EMBED_MAX_CHARS: int = 8192
|
| 63 |
RAG_OVERLAP_TOKENS: int = 64
|
|
@@ -73,3 +77,12 @@ MARKET_SWEEP_INTERVAL_SECONDS: int = 60
|
|
| 73 |
MARKET_DEFAULT_TTL_SECONDS: int = 86400 * 7 # 1 week
|
| 74 |
MARKET_MAX_TTL_SECONDS: int = 86400 * 30 # 30 days
|
| 75 |
MARKET_SEARCH_CACHE_MAX: int = 5000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
# ── RAG / Embedding ──────────────────────────────────────────────────────────
|
| 60 |
RAG_DEFAULT_CHUNK_SIZE_TOKENS: int = 512
|
| 61 |
+
|
| 62 |
+
# ── Rerank ────────────────────────────────────────────────────────────────────
|
| 63 |
+
RERANK_MAX_DOCS: int = 100
|
| 64 |
+
RERANK_LOAD_TIMEOUT_SECONDS: int = 60
|
| 65 |
EMBED_MAX_TEXTS: int = 256
|
| 66 |
EMBED_MAX_CHARS: int = 8192
|
| 67 |
RAG_OVERLAP_TOKENS: int = 64
|
|
|
|
| 77 |
MARKET_DEFAULT_TTL_SECONDS: int = 86400 * 7 # 1 week
|
| 78 |
MARKET_MAX_TTL_SECONDS: int = 86400 * 30 # 30 days
|
| 79 |
MARKET_SEARCH_CACHE_MAX: int = 5000
|
| 80 |
+
|
| 81 |
+
# ── STT / TTS ─────────────────────────────────────────────────────────────────
|
| 82 |
+
STT_MAX_AUDIO_SECONDS: int = 300
|
| 83 |
+
|
| 84 |
+
# ── Translation ───────────────────────────────────────────────────────────────
|
| 85 |
+
TRANSLATION_MAX_CHARS: int = 4000
|
| 86 |
+
|
| 87 |
+
# ── Rerank ────────────────────────────────────────────────────────────────────
|
| 88 |
+
RERANK_MAX_DOCS: int = 100
|
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class CryptoError(Exception):
|
| 5 |
+
"""Raised for all crypto-layer failures."""
|
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""File-chunk envelope encryption for HearthNet blobs (M23 / M07 extension)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import hashlib
|
| 5 |
+
import hmac
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
|
| 8 |
+
from hearthnet.crypto import CryptoError
|
| 9 |
+
|
| 10 |
+
try:
|
| 11 |
+
import nacl.secret
|
| 12 |
+
import nacl.utils
|
| 13 |
+
|
| 14 |
+
_NACL_AVAILABLE = True
|
| 15 |
+
except ImportError: # pragma: no cover
|
| 16 |
+
_NACL_AVAILABLE = False
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _require_nacl() -> None:
|
| 20 |
+
if not _NACL_AVAILABLE:
|
| 21 |
+
raise ImportError(
|
| 22 |
+
"PyNaCl is required for envelope encryption. Install it with: pip install pynacl"
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ---------------------------------------------------------------------------
|
| 27 |
+
# HKDF helper (local copy to keep this module self-contained)
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _hkdf_sha256(ikm: bytes, salt: bytes, info: bytes, length: int) -> bytes:
|
| 32 |
+
if not salt:
|
| 33 |
+
salt = b"\x00" * 32
|
| 34 |
+
prk = hmac.new(salt, ikm, hashlib.sha256).digest()
|
| 35 |
+
t = b""
|
| 36 |
+
okm = b""
|
| 37 |
+
i = 0
|
| 38 |
+
while len(okm) < length:
|
| 39 |
+
i += 1
|
| 40 |
+
t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
|
| 41 |
+
okm += t
|
| 42 |
+
return okm[:length]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
# EncryptedEnvelope
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass(frozen=True)
|
| 51 |
+
class EncryptedEnvelope:
|
| 52 |
+
"""An encrypted blob chunk with metadata."""
|
| 53 |
+
|
| 54 |
+
ciphertext: bytes
|
| 55 |
+
nonce: bytes # 24 bytes (XSalsa20 nonce)
|
| 56 |
+
key_id: str # identifies which key was used (e.g., recipient node_id or blob CID)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# ---------------------------------------------------------------------------
|
| 60 |
+
# Envelope encrypt / decrypt
|
| 61 |
+
# ---------------------------------------------------------------------------
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def envelope_encrypt(plaintext: bytes, key: bytes) -> EncryptedEnvelope:
|
| 65 |
+
"""Encrypt plaintext with XSalsa20-Poly1305 using the given 32-byte key."""
|
| 66 |
+
_require_nacl()
|
| 67 |
+
if len(key) != nacl.secret.SecretBox.KEY_SIZE:
|
| 68 |
+
raise CryptoError(
|
| 69 |
+
f"Key must be {nacl.secret.SecretBox.KEY_SIZE} bytes, got {len(key)}"
|
| 70 |
+
)
|
| 71 |
+
box = nacl.secret.SecretBox(key)
|
| 72 |
+
nonce = nacl.utils.random(nacl.secret.SecretBox.NONCE_SIZE)
|
| 73 |
+
ciphertext = bytes(box.encrypt(plaintext, nonce).ciphertext)
|
| 74 |
+
return EncryptedEnvelope(ciphertext=ciphertext, nonce=nonce, key_id="")
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def envelope_decrypt(envelope: EncryptedEnvelope, key: bytes) -> bytes:
|
| 78 |
+
"""Decrypt an EncryptedEnvelope using the given 32-byte key."""
|
| 79 |
+
_require_nacl()
|
| 80 |
+
if len(key) != nacl.secret.SecretBox.KEY_SIZE:
|
| 81 |
+
raise CryptoError(
|
| 82 |
+
f"Key must be {nacl.secret.SecretBox.KEY_SIZE} bytes, got {len(key)}"
|
| 83 |
+
)
|
| 84 |
+
box = nacl.secret.SecretBox(key)
|
| 85 |
+
try:
|
| 86 |
+
return bytes(box.decrypt(envelope.ciphertext, envelope.nonce))
|
| 87 |
+
except Exception as exc:
|
| 88 |
+
raise CryptoError(f"Envelope decryption failed: {exc}") from exc
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# ---------------------------------------------------------------------------
|
| 92 |
+
# Per-recipient key derivation
|
| 93 |
+
# ---------------------------------------------------------------------------
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def per_recipient_key(shared_secret: bytes, recipient_id: str, blob_cid: str) -> bytes:
|
| 97 |
+
"""Derive a 32-byte per-recipient encryption key via HKDF-SHA256.
|
| 98 |
+
|
| 99 |
+
Binds the key to the recipient identity and the specific blob CID so that
|
| 100 |
+
a key derived for one blob cannot decrypt another.
|
| 101 |
+
"""
|
| 102 |
+
info = f"HearthNet_BlobKey_v1:{recipient_id}:{blob_cid}".encode()
|
| 103 |
+
return _hkdf_sha256(shared_secret, salt=b"HearthNet_envelope", info=info, length=32)
|
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""X25519 key agreement + X3DH for HearthNet E2E encryption (M23)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import base64
|
| 5 |
+
import hashlib
|
| 6 |
+
import hmac
|
| 7 |
+
import time
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
from typing import TYPE_CHECKING
|
| 10 |
+
|
| 11 |
+
from hearthnet.crypto import CryptoError
|
| 12 |
+
|
| 13 |
+
if TYPE_CHECKING:
|
| 14 |
+
from hearthnet.identity.keys import KeyPair
|
| 15 |
+
|
| 16 |
+
try:
|
| 17 |
+
import nacl.bindings
|
| 18 |
+
import nacl.public
|
| 19 |
+
import nacl.signing
|
| 20 |
+
|
| 21 |
+
_NACL_AVAILABLE = True
|
| 22 |
+
except ImportError: # pragma: no cover
|
| 23 |
+
_NACL_AVAILABLE = False
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _require_nacl() -> None:
|
| 27 |
+
if not _NACL_AVAILABLE:
|
| 28 |
+
raise ImportError(
|
| 29 |
+
"PyNaCl is required for E2E encryption. Install it with: pip install pynacl"
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ---------------------------------------------------------------------------
|
| 34 |
+
# Helpers
|
| 35 |
+
# ---------------------------------------------------------------------------
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _b64url_encode(b: bytes) -> str:
|
| 39 |
+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _b64url_decode(s: str) -> bytes:
|
| 43 |
+
pad = 4 - len(s) % 4
|
| 44 |
+
if pad != 4:
|
| 45 |
+
s += "=" * pad
|
| 46 |
+
return base64.urlsafe_b64decode(s)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _hkdf_sha256(ikm: bytes, salt: bytes, info: bytes, length: int) -> bytes:
|
| 50 |
+
"""HKDF-SHA256 (RFC 5869)."""
|
| 51 |
+
if not salt:
|
| 52 |
+
salt = b"\x00" * 32
|
| 53 |
+
prk = hmac.new(salt, ikm, hashlib.sha256).digest()
|
| 54 |
+
t = b""
|
| 55 |
+
okm = b""
|
| 56 |
+
i = 0
|
| 57 |
+
while len(okm) < length:
|
| 58 |
+
i += 1
|
| 59 |
+
t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
|
| 60 |
+
okm += t
|
| 61 |
+
return okm[:length]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ---------------------------------------------------------------------------
|
| 65 |
+
# X25519 key types
|
| 66 |
+
# ---------------------------------------------------------------------------
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@dataclass(frozen=True)
|
| 70 |
+
class X25519KeyPair:
|
| 71 |
+
"""An X25519 Diffie-Hellman key pair."""
|
| 72 |
+
|
| 73 |
+
private: bytes # 32 bytes
|
| 74 |
+
public: bytes # 32 bytes
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ---------------------------------------------------------------------------
|
| 78 |
+
# X25519 primitives
|
| 79 |
+
# ---------------------------------------------------------------------------
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def x25519_generate() -> X25519KeyPair:
|
| 83 |
+
"""Generate a random X25519 key pair using PyNaCl."""
|
| 84 |
+
_require_nacl()
|
| 85 |
+
priv_key = nacl.public.PrivateKey.generate()
|
| 86 |
+
return X25519KeyPair(
|
| 87 |
+
private=bytes(priv_key),
|
| 88 |
+
public=bytes(priv_key.public_key),
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def x25519_dh(our_priv: bytes, their_pub: bytes) -> bytes:
|
| 93 |
+
"""Compute the X25519 Diffie-Hellman shared secret (32 bytes)."""
|
| 94 |
+
_require_nacl()
|
| 95 |
+
if len(our_priv) != 32:
|
| 96 |
+
raise CryptoError(f"Expected 32-byte private key, got {len(our_priv)}")
|
| 97 |
+
if len(their_pub) != 32:
|
| 98 |
+
raise CryptoError(f"Expected 32-byte public key, got {len(their_pub)}")
|
| 99 |
+
try:
|
| 100 |
+
return nacl.bindings.crypto_scalarmult(our_priv, their_pub)
|
| 101 |
+
except Exception as exc:
|
| 102 |
+
raise CryptoError(f"X25519 DH failed: {exc}") from exc
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ---------------------------------------------------------------------------
|
| 106 |
+
# Ed25519 → X25519 conversion
|
| 107 |
+
# ---------------------------------------------------------------------------
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def derive_identity_x25519_from_ed25519(ed_kp: KeyPair) -> X25519KeyPair:
|
| 111 |
+
"""Convert an Ed25519 signing key to X25519 using the standard nacl Elligator conversion."""
|
| 112 |
+
_require_nacl()
|
| 113 |
+
# Reconstruct the 64-byte Ed25519 secret key (seed || verify_key) via re-deriving
|
| 114 |
+
seed = bytes(ed_kp.signing_key) # 32-byte seed
|
| 115 |
+
_vk_bytes, sk_64 = nacl.bindings.crypto_sign_seed_keypair(seed)
|
| 116 |
+
x25519_priv = nacl.bindings.crypto_sign_ed25519_sk_to_curve25519(sk_64)
|
| 117 |
+
x25519_pub = nacl.bindings.crypto_sign_ed25519_pk_to_curve25519(bytes(ed_kp.verify_key))
|
| 118 |
+
return X25519KeyPair(private=x25519_priv, public=x25519_pub)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# ---------------------------------------------------------------------------
|
| 122 |
+
# Prekey bundle
|
| 123 |
+
# ---------------------------------------------------------------------------
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
@dataclass(frozen=True)
|
| 127 |
+
class PrekeyBundle:
|
| 128 |
+
"""What a recipient publishes so senders can establish a session without them online."""
|
| 129 |
+
|
| 130 |
+
identity_key_pub: bytes # 32 bytes (X25519 public key derived from Ed25519 identity)
|
| 131 |
+
signed_prekey_pub: bytes # 32 bytes
|
| 132 |
+
signed_prekey_sig: bytes # 64 bytes (Ed25519 signature over signed_prekey_pub)
|
| 133 |
+
one_time_prekeys: list[str] # base64url-encoded 32-byte X25519 public keys
|
| 134 |
+
published_at: int # unix seconds
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def build_prekey_bundle(
|
| 138 |
+
ed_kp: KeyPair,
|
| 139 |
+
num_one_time: int = 10,
|
| 140 |
+
) -> tuple[PrekeyBundle, X25519KeyPair, list[X25519KeyPair]]:
|
| 141 |
+
"""Build a prekey bundle from an Ed25519 identity key pair.
|
| 142 |
+
|
| 143 |
+
Returns (bundle, signed_prekey_full, one_time_prekeys_full).
|
| 144 |
+
Caller persists the private halves; publishes only the bundle.
|
| 145 |
+
"""
|
| 146 |
+
_require_nacl()
|
| 147 |
+
|
| 148 |
+
# Identity X25519 key (derived from Ed25519)
|
| 149 |
+
identity_x = derive_identity_x25519_from_ed25519(ed_kp)
|
| 150 |
+
|
| 151 |
+
# Signed prekey
|
| 152 |
+
signed_prekey_kp = x25519_generate()
|
| 153 |
+
|
| 154 |
+
# Sign the signed_prekey_pub with the Ed25519 identity key
|
| 155 |
+
try:
|
| 156 |
+
signed_msg = ed_kp.signing_key.sign(signed_prekey_kp.public)
|
| 157 |
+
signed_prekey_sig = signed_msg.signature # 64 bytes
|
| 158 |
+
except Exception as exc:
|
| 159 |
+
raise CryptoError(f"Failed to sign prekey: {exc}") from exc
|
| 160 |
+
|
| 161 |
+
# One-time prekeys
|
| 162 |
+
otp_kps: list[X25519KeyPair] = [x25519_generate() for _ in range(num_one_time)]
|
| 163 |
+
otp_pubs = [_b64url_encode(kp.public) for kp in otp_kps]
|
| 164 |
+
|
| 165 |
+
bundle = PrekeyBundle(
|
| 166 |
+
identity_key_pub=identity_x.public,
|
| 167 |
+
signed_prekey_pub=signed_prekey_kp.public,
|
| 168 |
+
signed_prekey_sig=bytes(signed_prekey_sig),
|
| 169 |
+
one_time_prekeys=otp_pubs,
|
| 170 |
+
published_at=int(time.time()),
|
| 171 |
+
)
|
| 172 |
+
return bundle, signed_prekey_kp, otp_kps
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
# ---------------------------------------------------------------------------
|
| 176 |
+
# X3DH key agreement
|
| 177 |
+
# ---------------------------------------------------------------------------
|
| 178 |
+
|
| 179 |
+
_X3DH_F = b"\xff" * 32 # Fixed padding as per Signal spec
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _x3dh_kdf(*dh_outputs: bytes) -> bytes:
|
| 183 |
+
"""KDF over concatenated DH outputs → 32-byte shared secret."""
|
| 184 |
+
ikm = _X3DH_F + b"".join(dh_outputs)
|
| 185 |
+
return _hkdf_sha256(ikm, salt=b"HearthNet_X3DH_v1", info=b"HearthNet_X3DH_v1", length=32)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def x3dh_initiator(
|
| 189 |
+
our_identity_x: X25519KeyPair,
|
| 190 |
+
our_ephemeral_kp: X25519KeyPair,
|
| 191 |
+
their_bundle: PrekeyBundle,
|
| 192 |
+
) -> tuple[bytes, dict]:
|
| 193 |
+
"""X3DH from the initiator's side.
|
| 194 |
+
|
| 195 |
+
Returns (shared_secret_32bytes, session_init_message).
|
| 196 |
+
session_init_message carries public material needed for the responder.
|
| 197 |
+
"""
|
| 198 |
+
_require_nacl()
|
| 199 |
+
|
| 200 |
+
# DH1 = DH(IK_a, SPK_b)
|
| 201 |
+
dh1 = x25519_dh(our_identity_x.private, their_bundle.signed_prekey_pub)
|
| 202 |
+
# DH2 = DH(EK_a, IK_b)
|
| 203 |
+
dh2 = x25519_dh(our_ephemeral_kp.private, their_bundle.identity_key_pub)
|
| 204 |
+
# DH3 = DH(EK_a, SPK_b)
|
| 205 |
+
dh3 = x25519_dh(our_ephemeral_kp.private, their_bundle.signed_prekey_pub)
|
| 206 |
+
|
| 207 |
+
# Optional: DH4 = DH(EK_a, OTP_b)
|
| 208 |
+
used_otp_index: int | None = None
|
| 209 |
+
dh_outputs = [dh1, dh2, dh3]
|
| 210 |
+
if their_bundle.one_time_prekeys:
|
| 211 |
+
used_otp_index = 0
|
| 212 |
+
otp_pub_bytes = _b64url_decode(their_bundle.one_time_prekeys[0])
|
| 213 |
+
dh4 = x25519_dh(our_ephemeral_kp.private, otp_pub_bytes)
|
| 214 |
+
dh_outputs.append(dh4)
|
| 215 |
+
|
| 216 |
+
shared_secret = _x3dh_kdf(*dh_outputs)
|
| 217 |
+
|
| 218 |
+
session_init_message = {
|
| 219 |
+
"identity_pub": _b64url_encode(our_identity_x.public),
|
| 220 |
+
"ephemeral_pub": _b64url_encode(our_ephemeral_kp.public),
|
| 221 |
+
"signed_prekey_pub": _b64url_encode(their_bundle.signed_prekey_pub),
|
| 222 |
+
"used_otp_index": used_otp_index,
|
| 223 |
+
"used_otp_pub": (
|
| 224 |
+
their_bundle.one_time_prekeys[0] if used_otp_index is not None else None
|
| 225 |
+
),
|
| 226 |
+
}
|
| 227 |
+
return shared_secret, session_init_message
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def x3dh_responder(
|
| 231 |
+
our_identity_x: X25519KeyPair,
|
| 232 |
+
our_signed_prekey: X25519KeyPair,
|
| 233 |
+
used_one_time_prekey: X25519KeyPair | None,
|
| 234 |
+
their_ephemeral_pub: bytes,
|
| 235 |
+
their_identity_pub: bytes,
|
| 236 |
+
) -> bytes:
|
| 237 |
+
"""X3DH from the responder's side. Returns the shared secret (32 bytes)."""
|
| 238 |
+
_require_nacl()
|
| 239 |
+
|
| 240 |
+
# DH1 = DH(SPK_b, IK_a)
|
| 241 |
+
dh1 = x25519_dh(our_signed_prekey.private, their_identity_pub)
|
| 242 |
+
# DH2 = DH(IK_b, EK_a)
|
| 243 |
+
dh2 = x25519_dh(our_identity_x.private, their_ephemeral_pub)
|
| 244 |
+
# DH3 = DH(SPK_b, EK_a)
|
| 245 |
+
dh3 = x25519_dh(our_signed_prekey.private, their_ephemeral_pub)
|
| 246 |
+
|
| 247 |
+
# Optional: DH4 = DH(OTP_b, EK_a)
|
| 248 |
+
dh_outputs = [dh1, dh2, dh3]
|
| 249 |
+
if used_one_time_prekey is not None:
|
| 250 |
+
dh4 = x25519_dh(used_one_time_prekey.private, their_ephemeral_pub)
|
| 251 |
+
dh_outputs.append(dh4)
|
| 252 |
+
|
| 253 |
+
return _x3dh_kdf(*dh_outputs)
|
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prekey bundle storage for HearthNet E2E encryption (M23)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import base64
|
| 5 |
+
import json
|
| 6 |
+
import sqlite3
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from hearthnet.crypto import CryptoError
|
| 10 |
+
from hearthnet.crypto.kem import PrekeyBundle, X25519KeyPair
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _b64url_encode(b: bytes) -> str:
|
| 14 |
+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _b64url_decode(s: str) -> bytes:
|
| 18 |
+
pad = 4 - len(s) % 4
|
| 19 |
+
if pad != 4:
|
| 20 |
+
s += "=" * pad
|
| 21 |
+
return base64.urlsafe_b64decode(s)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
# PrekeyStore
|
| 26 |
+
# ---------------------------------------------------------------------------
|
| 27 |
+
|
| 28 |
+
_SCHEMA = """
|
| 29 |
+
CREATE TABLE IF NOT EXISTS prekey_bundles (
|
| 30 |
+
id INTEGER PRIMARY KEY,
|
| 31 |
+
bundle_json TEXT NOT NULL,
|
| 32 |
+
private_keys_json TEXT NOT NULL,
|
| 33 |
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
| 34 |
+
);
|
| 35 |
+
|
| 36 |
+
CREATE TABLE IF NOT EXISTS one_time_prekeys (
|
| 37 |
+
pub_key_b64 TEXT PRIMARY KEY,
|
| 38 |
+
private_key_b64 TEXT NOT NULL,
|
| 39 |
+
used INTEGER NOT NULL DEFAULT 0
|
| 40 |
+
);
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class PrekeyStore:
|
| 45 |
+
"""Persistent store for prekey bundles backed by SQLite."""
|
| 46 |
+
|
| 47 |
+
def __init__(self, db_path: str | Path = ":memory:") -> None:
|
| 48 |
+
self._db_path = str(db_path)
|
| 49 |
+
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
| 50 |
+
self._conn.executescript(_SCHEMA)
|
| 51 |
+
self._conn.commit()
|
| 52 |
+
|
| 53 |
+
def close(self) -> None:
|
| 54 |
+
self._conn.close()
|
| 55 |
+
|
| 56 |
+
# ------------------------------------------------------------------
|
| 57 |
+
# Bundle persistence
|
| 58 |
+
# ------------------------------------------------------------------
|
| 59 |
+
|
| 60 |
+
def store_bundle(
|
| 61 |
+
self,
|
| 62 |
+
bundle: PrekeyBundle,
|
| 63 |
+
signed_prekey_kp: X25519KeyPair,
|
| 64 |
+
otp_kps: list[X25519KeyPair],
|
| 65 |
+
) -> None:
|
| 66 |
+
"""Persist the full bundle (public) and private key halves."""
|
| 67 |
+
bundle_dict = {
|
| 68 |
+
"identity_key_pub": _b64url_encode(bundle.identity_key_pub),
|
| 69 |
+
"signed_prekey_pub": _b64url_encode(bundle.signed_prekey_pub),
|
| 70 |
+
"signed_prekey_sig": _b64url_encode(bundle.signed_prekey_sig),
|
| 71 |
+
"one_time_prekeys": bundle.one_time_prekeys,
|
| 72 |
+
"published_at": bundle.published_at,
|
| 73 |
+
}
|
| 74 |
+
private_dict = {
|
| 75 |
+
"signed_prekey_priv": _b64url_encode(signed_prekey_kp.private),
|
| 76 |
+
}
|
| 77 |
+
with self._conn:
|
| 78 |
+
self._conn.execute(
|
| 79 |
+
"DELETE FROM prekey_bundles",
|
| 80 |
+
)
|
| 81 |
+
self._conn.execute(
|
| 82 |
+
"INSERT INTO prekey_bundles (bundle_json, private_keys_json) VALUES (?, ?)",
|
| 83 |
+
(json.dumps(bundle_dict), json.dumps(private_dict)),
|
| 84 |
+
)
|
| 85 |
+
# Store one-time prekeys
|
| 86 |
+
for kp in otp_kps:
|
| 87 |
+
self._conn.execute(
|
| 88 |
+
"INSERT OR REPLACE INTO one_time_prekeys (pub_key_b64, private_key_b64) "
|
| 89 |
+
"VALUES (?, ?)",
|
| 90 |
+
(_b64url_encode(kp.public), _b64url_encode(kp.private)),
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
def load_bundle(self) -> tuple[PrekeyBundle, dict]:
|
| 94 |
+
"""Load the stored bundle and private key map.
|
| 95 |
+
|
| 96 |
+
Returns (PrekeyBundle, private_keys_dict) where private_keys_dict has
|
| 97 |
+
'signed_prekey_priv' as a base64url string.
|
| 98 |
+
Raises CryptoError if no bundle is stored.
|
| 99 |
+
"""
|
| 100 |
+
row = self._conn.execute(
|
| 101 |
+
"SELECT bundle_json, private_keys_json FROM prekey_bundles ORDER BY id DESC LIMIT 1"
|
| 102 |
+
).fetchone()
|
| 103 |
+
if row is None:
|
| 104 |
+
raise CryptoError("No prekey bundle stored")
|
| 105 |
+
bundle_dict = json.loads(row[0])
|
| 106 |
+
private_dict = json.loads(row[1])
|
| 107 |
+
# Rebuild one_time_prekeys from the active (unused) OTPs stored in the DB
|
| 108 |
+
otp_rows = self._conn.execute(
|
| 109 |
+
"SELECT pub_key_b64 FROM one_time_prekeys WHERE used = 0"
|
| 110 |
+
).fetchall()
|
| 111 |
+
active_otps = [r[0] for r in otp_rows]
|
| 112 |
+
bundle = PrekeyBundle(
|
| 113 |
+
identity_key_pub=_b64url_decode(bundle_dict["identity_key_pub"]),
|
| 114 |
+
signed_prekey_pub=_b64url_decode(bundle_dict["signed_prekey_pub"]),
|
| 115 |
+
signed_prekey_sig=_b64url_decode(bundle_dict["signed_prekey_sig"]),
|
| 116 |
+
one_time_prekeys=active_otps,
|
| 117 |
+
published_at=bundle_dict["published_at"],
|
| 118 |
+
)
|
| 119 |
+
return bundle, private_dict
|
| 120 |
+
|
| 121 |
+
def consume_one_time_prekey(self, pub_key_b64: str) -> X25519KeyPair | None:
|
| 122 |
+
"""Mark a one-time prekey as used and return its key pair.
|
| 123 |
+
|
| 124 |
+
Returns None if the key does not exist or was already consumed.
|
| 125 |
+
"""
|
| 126 |
+
row = self._conn.execute(
|
| 127 |
+
"SELECT private_key_b64 FROM one_time_prekeys WHERE pub_key_b64 = ? AND used = 0",
|
| 128 |
+
(pub_key_b64,),
|
| 129 |
+
).fetchone()
|
| 130 |
+
if row is None:
|
| 131 |
+
return None
|
| 132 |
+
with self._conn:
|
| 133 |
+
self._conn.execute(
|
| 134 |
+
"UPDATE one_time_prekeys SET used = 1 WHERE pub_key_b64 = ?",
|
| 135 |
+
(pub_key_b64,),
|
| 136 |
+
)
|
| 137 |
+
priv = _b64url_decode(row[0])
|
| 138 |
+
pub = _b64url_decode(pub_key_b64)
|
| 139 |
+
return X25519KeyPair(private=priv, public=pub)
|
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Double Ratchet session for HearthNet E2E encryption (M23)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import base64
|
| 5 |
+
import hashlib
|
| 6 |
+
import hmac
|
| 7 |
+
from dataclasses import dataclass, field
|
| 8 |
+
|
| 9 |
+
from hearthnet.crypto import CryptoError
|
| 10 |
+
|
| 11 |
+
try:
|
| 12 |
+
import nacl.secret
|
| 13 |
+
import nacl.utils
|
| 14 |
+
|
| 15 |
+
_NACL_AVAILABLE = True
|
| 16 |
+
except ImportError: # pragma: no cover
|
| 17 |
+
_NACL_AVAILABLE = False
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _require_nacl() -> None:
|
| 21 |
+
if not _NACL_AVAILABLE:
|
| 22 |
+
raise ImportError(
|
| 23 |
+
"PyNaCl is required for the Double Ratchet. Install it with: pip install pynacl"
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
# Internal KDF helpers
|
| 29 |
+
# ---------------------------------------------------------------------------
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _hkdf_sha256(ikm: bytes, salt: bytes, info: bytes, length: int) -> bytes:
|
| 33 |
+
if not salt:
|
| 34 |
+
salt = b"\x00" * 32
|
| 35 |
+
prk = hmac.new(salt, ikm, hashlib.sha256).digest()
|
| 36 |
+
t = b""
|
| 37 |
+
okm = b""
|
| 38 |
+
i = 0
|
| 39 |
+
while len(okm) < length:
|
| 40 |
+
i += 1
|
| 41 |
+
t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
|
| 42 |
+
okm += t
|
| 43 |
+
return okm[:length]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _kdf_chain(chain_key: bytes) -> tuple[bytes, bytes]:
|
| 47 |
+
"""Advance the symmetric chain. Returns (message_key, next_chain_key)."""
|
| 48 |
+
msg_key = hmac.new(chain_key, b"\x01", hashlib.sha256).digest()
|
| 49 |
+
next_ck = hmac.new(chain_key, b"\x02", hashlib.sha256).digest()
|
| 50 |
+
return msg_key, next_ck
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _kdf_root_key(root_key: bytes, dh_out: bytes) -> tuple[bytes, bytes]:
|
| 54 |
+
"""KDF_RK: returns (new_root_key, new_chain_key) from root_key + DH output."""
|
| 55 |
+
kdf_out = _hkdf_sha256(dh_out, salt=root_key, info=b"HearthNet_RK_v1", length=64)
|
| 56 |
+
return kdf_out[:32], kdf_out[32:]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _b64url_encode(b: bytes) -> str:
|
| 60 |
+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _b64url_decode(s: str) -> bytes:
|
| 64 |
+
pad = 4 - len(s) % 4
|
| 65 |
+
if pad != 4:
|
| 66 |
+
s += "=" * pad
|
| 67 |
+
return base64.urlsafe_b64decode(s)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _secretbox_encrypt(key: bytes, plaintext: bytes) -> bytes:
|
| 71 |
+
"""Encrypt plaintext with XSalsa20-Poly1305. Returns nonce+ciphertext."""
|
| 72 |
+
box = nacl.secret.SecretBox(key)
|
| 73 |
+
return bytes(box.encrypt(plaintext))
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _secretbox_decrypt(key: bytes, data: bytes) -> bytes:
|
| 77 |
+
"""Decrypt nonce+ciphertext with XSalsa20-Poly1305."""
|
| 78 |
+
box = nacl.secret.SecretBox(key)
|
| 79 |
+
try:
|
| 80 |
+
return bytes(box.decrypt(data))
|
| 81 |
+
except Exception as exc:
|
| 82 |
+
raise CryptoError(f"Ratchet decryption failed: {exc}") from exc
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
# Ratchet session state
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
|
| 89 |
+
_MAX_SKIP = 100 # Safety cap on how many messages can be skipped
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@dataclass
|
| 93 |
+
class RatchetSession:
|
| 94 |
+
"""Bidirectional Double Ratchet session between two nodes."""
|
| 95 |
+
|
| 96 |
+
peer_node_id_full: str
|
| 97 |
+
root_key: bytes
|
| 98 |
+
chain_key: bytes # send chain key
|
| 99 |
+
recv_chain_key: bytes # receive chain key
|
| 100 |
+
message_keys: dict[tuple[int, int], bytes] = field(default_factory=dict)
|
| 101 |
+
send_counter: int = 0
|
| 102 |
+
recv_counter: int = 0
|
| 103 |
+
epoch: int = 0 # DH ratchet step index
|
| 104 |
+
|
| 105 |
+
# X25519 ratchet keys (stored as raw bytes)
|
| 106 |
+
ratchet_priv: bytes | None = None # our current DH ratchet private key
|
| 107 |
+
ratchet_pub: bytes | None = None # our current DH ratchet public key
|
| 108 |
+
remote_ratchet_pub: bytes | None = None # peer's current DH ratchet public key
|
| 109 |
+
|
| 110 |
+
# Set to True for the responder (Bob): the first receive must NOT trigger a
|
| 111 |
+
# DH ratchet step because the initial recv_chain_key is already established
|
| 112 |
+
# from X3DH. The DH ratchet only fires when epoch in the header advances.
|
| 113 |
+
is_initiator: bool = True
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# ---------------------------------------------------------------------------
|
| 117 |
+
# Public API
|
| 118 |
+
# ---------------------------------------------------------------------------
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def init_from_shared_secret(
|
| 122 |
+
shared_secret: bytes,
|
| 123 |
+
is_initiator: bool,
|
| 124 |
+
peer_node_id_full: str = "",
|
| 125 |
+
) -> RatchetSession:
|
| 126 |
+
"""Initialise a new ratchet session from an X3DH shared secret."""
|
| 127 |
+
_require_nacl()
|
| 128 |
+
|
| 129 |
+
from hearthnet.crypto.kem import x25519_generate
|
| 130 |
+
|
| 131 |
+
root_key = _hkdf_sha256(
|
| 132 |
+
shared_secret,
|
| 133 |
+
salt=b"HearthNet_X3DH",
|
| 134 |
+
info=b"RootKey_v1",
|
| 135 |
+
length=32,
|
| 136 |
+
)
|
| 137 |
+
info_send = b"InitiatorChain_v1" if is_initiator else b"ResponderChain_v1"
|
| 138 |
+
info_recv = b"ResponderChain_v1" if is_initiator else b"InitiatorChain_v1"
|
| 139 |
+
chain_key = _hkdf_sha256(root_key, salt=b"", info=info_send, length=32)
|
| 140 |
+
recv_chain_key = _hkdf_sha256(root_key, salt=b"", info=info_recv, length=32)
|
| 141 |
+
|
| 142 |
+
ratchet_kp = x25519_generate()
|
| 143 |
+
return RatchetSession(
|
| 144 |
+
peer_node_id_full=peer_node_id_full,
|
| 145 |
+
root_key=root_key,
|
| 146 |
+
chain_key=chain_key,
|
| 147 |
+
recv_chain_key=recv_chain_key,
|
| 148 |
+
ratchet_priv=ratchet_kp.private,
|
| 149 |
+
ratchet_pub=ratchet_kp.public,
|
| 150 |
+
is_initiator=is_initiator,
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _dh_ratchet_step(session: RatchetSession, new_remote_ratchet_pub: bytes) -> None:
|
| 155 |
+
"""Perform a DH ratchet step on receiving a new remote ratchet public key."""
|
| 156 |
+
from hearthnet.crypto.kem import x25519_dh, x25519_generate
|
| 157 |
+
|
| 158 |
+
assert session.ratchet_priv is not None
|
| 159 |
+
|
| 160 |
+
# Step 1: derive new recv chain key from DH(our_current, their_new)
|
| 161 |
+
dh1 = x25519_dh(session.ratchet_priv, new_remote_ratchet_pub)
|
| 162 |
+
root_key, recv_chain_key = _kdf_root_key(session.root_key, dh1)
|
| 163 |
+
|
| 164 |
+
# Step 2: generate new DH ratchet keypair
|
| 165 |
+
new_kp = x25519_generate()
|
| 166 |
+
|
| 167 |
+
# Step 3: derive new send chain key from DH(our_new, their_new)
|
| 168 |
+
dh2 = x25519_dh(new_kp.private, new_remote_ratchet_pub)
|
| 169 |
+
root_key2, send_chain_key = _kdf_root_key(root_key, dh2)
|
| 170 |
+
|
| 171 |
+
# Mutate session state
|
| 172 |
+
session.root_key = root_key2
|
| 173 |
+
session.chain_key = send_chain_key
|
| 174 |
+
session.recv_chain_key = recv_chain_key
|
| 175 |
+
session.remote_ratchet_pub = new_remote_ratchet_pub
|
| 176 |
+
session.ratchet_priv = new_kp.private
|
| 177 |
+
session.ratchet_pub = new_kp.public
|
| 178 |
+
session.epoch += 1
|
| 179 |
+
session.send_counter = 0
|
| 180 |
+
session.recv_counter = 0
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def encrypt(session: RatchetSession, plaintext: bytes) -> tuple[bytes, dict]:
|
| 184 |
+
"""Encrypt a message. Returns (ciphertext, header).
|
| 185 |
+
|
| 186 |
+
header contains: ratchet_pub (b64url), index, epoch.
|
| 187 |
+
"""
|
| 188 |
+
_require_nacl()
|
| 189 |
+
|
| 190 |
+
msg_key, next_ck = _kdf_chain(session.chain_key)
|
| 191 |
+
session.chain_key = next_ck
|
| 192 |
+
counter = session.send_counter
|
| 193 |
+
session.send_counter += 1
|
| 194 |
+
|
| 195 |
+
ciphertext = _secretbox_encrypt(msg_key, plaintext)
|
| 196 |
+
|
| 197 |
+
header = {
|
| 198 |
+
"ratchet_pub": _b64url_encode(session.ratchet_pub or b"\x00" * 32),
|
| 199 |
+
"index": counter,
|
| 200 |
+
"epoch": session.epoch,
|
| 201 |
+
}
|
| 202 |
+
return ciphertext, header
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def decrypt(session: RatchetSession, ciphertext: bytes, header: dict) -> bytes:
|
| 206 |
+
"""Decrypt a message using ratchet state. Handles out-of-order messages (limited)."""
|
| 207 |
+
_require_nacl()
|
| 208 |
+
|
| 209 |
+
ratchet_pub = _b64url_decode(header["ratchet_pub"])
|
| 210 |
+
index: int = header["index"]
|
| 211 |
+
epoch: int = header["epoch"]
|
| 212 |
+
|
| 213 |
+
# Check the skipped-message cache first
|
| 214 |
+
cached_key = session.message_keys.pop((epoch, index), None)
|
| 215 |
+
if cached_key is not None:
|
| 216 |
+
return _secretbox_decrypt(cached_key, ciphertext)
|
| 217 |
+
|
| 218 |
+
# DH ratchet step rules:
|
| 219 |
+
# - Initiator's first decrypt: remote_ratchet_pub is None but epoch==0
|
| 220 |
+
# means the responder is starting their first reply — do the DH step.
|
| 221 |
+
# - Responder's first decrypt (epoch==0): the initial recv_chain_key is
|
| 222 |
+
# already set from X3DH; just record the peer's ratchet pub, no DH step.
|
| 223 |
+
# - Any subsequent epoch mismatch: always do the DH step.
|
| 224 |
+
if ratchet_pub != session.remote_ratchet_pub:
|
| 225 |
+
if session.remote_ratchet_pub is None and not session.is_initiator and epoch == 0:
|
| 226 |
+
# Responder receiving initiator's first message — use pre-computed
|
| 227 |
+
# recv_chain_key as-is; just record the initiator's ratchet pub.
|
| 228 |
+
session.remote_ratchet_pub = ratchet_pub
|
| 229 |
+
else:
|
| 230 |
+
_dh_ratchet_step(session, ratchet_pub)
|
| 231 |
+
|
| 232 |
+
# Advance recv chain to the target counter, caching skipped keys
|
| 233 |
+
skipped = 0
|
| 234 |
+
while session.recv_counter < index:
|
| 235 |
+
if skipped >= _MAX_SKIP:
|
| 236 |
+
raise CryptoError("Too many skipped messages in ratchet")
|
| 237 |
+
msg_key, next_ck = _kdf_chain(session.recv_chain_key)
|
| 238 |
+
session.recv_chain_key = next_ck
|
| 239 |
+
session.message_keys[(session.epoch, session.recv_counter)] = msg_key
|
| 240 |
+
session.recv_counter += 1
|
| 241 |
+
skipped += 1
|
| 242 |
+
|
| 243 |
+
msg_key, next_ck = _kdf_chain(session.recv_chain_key)
|
| 244 |
+
session.recv_chain_key = next_ck
|
| 245 |
+
session.recv_counter += 1
|
| 246 |
+
|
| 247 |
+
return _secretbox_decrypt(msg_key, ciphertext)
|
|
File without changes
|
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class BootstrapConfig:
|
| 11 |
+
peers: list[str]
|
| 12 |
+
relay_url: str | None = None
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
_DEFAULT_PEERS: list[str] = ["relay.hearthnet.de:7080"]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def load_bootstrap(config_path: str | Path | None = None) -> BootstrapConfig:
|
| 19 |
+
"""Load bootstrap configuration from a JSON file, or return defaults.
|
| 20 |
+
|
| 21 |
+
The config file is optional and its absence or any error is handled
|
| 22 |
+
gracefully by returning the built-in default peers.
|
| 23 |
+
|
| 24 |
+
Expected JSON schema::
|
| 25 |
+
|
| 26 |
+
{
|
| 27 |
+
"peers": ["host:port", ...],
|
| 28 |
+
"relay_url": "https://relay.hearthnet.de" // optional
|
| 29 |
+
}
|
| 30 |
+
"""
|
| 31 |
+
if config_path is not None:
|
| 32 |
+
path = Path(config_path)
|
| 33 |
+
if path.is_file():
|
| 34 |
+
try:
|
| 35 |
+
data: Any = json.loads(path.read_text(encoding="utf-8"))
|
| 36 |
+
peers: list[str] = data.get("peers", _DEFAULT_PEERS)
|
| 37 |
+
relay_url: str | None = data.get("relay_url")
|
| 38 |
+
return BootstrapConfig(peers=peers, relay_url=relay_url)
|
| 39 |
+
except Exception:
|
| 40 |
+
pass # fall through to defaults
|
| 41 |
+
|
| 42 |
+
# Auto-discover relay_url from XDG config if possible
|
| 43 |
+
try:
|
| 44 |
+
from hearthnet.config import _default_config_path, load # noqa: PLC0415
|
| 45 |
+
|
| 46 |
+
cfg_file = _default_config_path()
|
| 47 |
+
if cfg_file.is_file():
|
| 48 |
+
cfg = load(cfg_file)
|
| 49 |
+
relay_url = getattr(cfg, "relay_url", None)
|
| 50 |
+
return BootstrapConfig(peers=list(_DEFAULT_PEERS), relay_url=relay_url)
|
| 51 |
+
except Exception:
|
| 52 |
+
pass
|
| 53 |
+
|
| 54 |
+
return BootstrapConfig(peers=list(_DEFAULT_PEERS), relay_url=None)
|
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import time
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass(frozen=True)
|
| 10 |
+
class DhtContact:
|
| 11 |
+
node_key: bytes # 32-byte SHA-256 of node_id
|
| 12 |
+
endpoint: str # "host:port"
|
| 13 |
+
node_id: str # human-readable node identifier
|
| 14 |
+
last_seen: float # monotonic timestamp
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass(frozen=True)
|
| 18 |
+
class DhtValue:
|
| 19 |
+
key: bytes # lookup key (arbitrary bytes)
|
| 20 |
+
payload: dict # stored data
|
| 21 |
+
expires_at: int # Unix epoch seconds
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _xor_distance(a: bytes, b: bytes) -> int:
|
| 25 |
+
"""XOR metric over equal-length byte strings, returned as integer."""
|
| 26 |
+
# Pad to same length if needed
|
| 27 |
+
la, lb = len(a), len(b)
|
| 28 |
+
if la < lb:
|
| 29 |
+
a = a.ljust(lb, b"\x00")
|
| 30 |
+
elif lb < la:
|
| 31 |
+
b = b.ljust(la, b"\x00")
|
| 32 |
+
result = 0
|
| 33 |
+
for x, y in zip(a, b):
|
| 34 |
+
result = (result << 8) | (x ^ y)
|
| 35 |
+
return result
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _bucket_index(own_key: bytes, target_key: bytes) -> int:
|
| 39 |
+
"""Return the Kademlia bucket index [0, 255] for the target key."""
|
| 40 |
+
dist = _xor_distance(own_key, target_key)
|
| 41 |
+
if dist == 0:
|
| 42 |
+
return 0
|
| 43 |
+
# Most-significant bit position of the XOR distance
|
| 44 |
+
bit_length = dist.bit_length()
|
| 45 |
+
return bit_length - 1 # 0-based, max 255 for 32-byte keys
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class RoutingTable:
|
| 49 |
+
"""256 buckets of K=8 contacts each."""
|
| 50 |
+
|
| 51 |
+
def __init__(self, own_key: bytes, k: int = 8) -> None:
|
| 52 |
+
self._own_key = own_key
|
| 53 |
+
self._k = k
|
| 54 |
+
self._buckets: list[list[DhtContact]] = [[] for _ in range(256)]
|
| 55 |
+
|
| 56 |
+
def add_contact(self, contact: DhtContact) -> None:
|
| 57 |
+
if contact.node_key == self._own_key:
|
| 58 |
+
return
|
| 59 |
+
idx = _bucket_index(self._own_key, contact.node_key)
|
| 60 |
+
bucket = self._buckets[idx]
|
| 61 |
+
|
| 62 |
+
# Replace existing entry for the same node_key
|
| 63 |
+
for i, existing in enumerate(bucket):
|
| 64 |
+
if existing.node_key == contact.node_key:
|
| 65 |
+
bucket[i] = contact
|
| 66 |
+
return
|
| 67 |
+
|
| 68 |
+
if len(bucket) < self._k:
|
| 69 |
+
bucket.append(contact)
|
| 70 |
+
else:
|
| 71 |
+
# Replace the oldest (least recently seen) contact
|
| 72 |
+
oldest_idx = min(range(len(bucket)), key=lambda i: bucket[i].last_seen)
|
| 73 |
+
bucket[oldest_idx] = contact
|
| 74 |
+
|
| 75 |
+
def find_closest(self, key: bytes, k: int = 8) -> list[DhtContact]:
|
| 76 |
+
"""Return up to k contacts closest (by XOR) to key."""
|
| 77 |
+
all_contacts = self.all_contacts()
|
| 78 |
+
all_contacts.sort(key=lambda c: _xor_distance(c.node_key, key))
|
| 79 |
+
return all_contacts[:k]
|
| 80 |
+
|
| 81 |
+
def size(self) -> int:
|
| 82 |
+
return sum(len(b) for b in self._buckets)
|
| 83 |
+
|
| 84 |
+
def all_contacts(self) -> list[DhtContact]:
|
| 85 |
+
contacts: list[DhtContact] = []
|
| 86 |
+
for bucket in self._buckets:
|
| 87 |
+
contacts.extend(bucket)
|
| 88 |
+
return contacts
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class KademliaNode:
|
| 92 |
+
"""Local Kademlia DHT node: routing table + local value store."""
|
| 93 |
+
|
| 94 |
+
def __init__(self, node_id: str, k: int = 8, alpha: int = 3) -> None:
|
| 95 |
+
self._node_id = node_id
|
| 96 |
+
self._k = k
|
| 97 |
+
self._alpha = alpha
|
| 98 |
+
|
| 99 |
+
# Deterministic 32-byte key from node_id
|
| 100 |
+
self.node_key: bytes = hashlib.sha256(node_id.encode()).digest()
|
| 101 |
+
|
| 102 |
+
self.routing_table = RoutingTable(own_key=self.node_key, k=k)
|
| 103 |
+
self.local_store: dict[bytes, DhtValue] = {}
|
| 104 |
+
|
| 105 |
+
# ── Value store ───────────────────────────────────────────────────────────
|
| 106 |
+
|
| 107 |
+
def store(self, key: bytes, value: dict, ttl: int = 3600) -> None:
|
| 108 |
+
expires_at = int(time.time()) + ttl
|
| 109 |
+
self.local_store[key] = DhtValue(key=key, payload=value, expires_at=expires_at)
|
| 110 |
+
|
| 111 |
+
def find_value(self, key: bytes) -> DhtValue | None:
|
| 112 |
+
entry = self.local_store.get(key)
|
| 113 |
+
if entry is None:
|
| 114 |
+
return None
|
| 115 |
+
if int(time.time()) > entry.expires_at:
|
| 116 |
+
del self.local_store[key]
|
| 117 |
+
return None
|
| 118 |
+
return entry
|
| 119 |
+
|
| 120 |
+
# ── Routing ───────────────────────────────────────────────────────────────
|
| 121 |
+
|
| 122 |
+
def find_closest(self, key: bytes, k: int = 8) -> list[DhtContact]:
|
| 123 |
+
return self.routing_table.find_closest(key, k)
|
| 124 |
+
|
| 125 |
+
def update_contact(self, contact: DhtContact) -> None:
|
| 126 |
+
self.routing_table.add_contact(contact)
|
| 127 |
+
|
| 128 |
+
# ── Maintenance ───────────────────────────────────────────────────────────
|
| 129 |
+
|
| 130 |
+
def expire_stale(self) -> int:
|
| 131 |
+
"""Remove expired values. Returns count of removed entries."""
|
| 132 |
+
now = int(time.time())
|
| 133 |
+
stale = [k for k, v in self.local_store.items() if now > v.expires_at]
|
| 134 |
+
for k in stale:
|
| 135 |
+
del self.local_store[k]
|
| 136 |
+
return len(stale)
|
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""M26 — Distributed Inference (experimental, Phase 3).
|
| 2 |
+
|
| 3 |
+
Gated by config.research.distributed_inference = True.
|
| 4 |
+
Layer-shards an LLM across multiple LAN nodes (Petals-style).
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from hearthnet.distributed_inference.shard import ShardDescriptor, ShardServer
|
| 9 |
+
from hearthnet.distributed_inference.pipeline import Pipeline, PipelineOrchestrator
|
| 10 |
+
|
| 11 |
+
__all__ = ["ShardDescriptor", "ShardServer", "Pipeline", "PipelineOrchestrator"]
|
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pipeline orchestrator for distributed inference (M26 — experimental)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import time
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
|
| 7 |
+
from hearthnet.distributed_inference.shard import ShardDescriptor
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class Pipeline:
|
| 12 |
+
"""A planned pipeline: ordered list of shards covering layers 0..N."""
|
| 13 |
+
pipeline_id: str
|
| 14 |
+
model_id: str
|
| 15 |
+
shards: list[ShardDescriptor]
|
| 16 |
+
established_at: float = field(default_factory=time.time)
|
| 17 |
+
status: str = "planned" # "planned" | "active" | "failed" | "done"
|
| 18 |
+
|
| 19 |
+
@property
|
| 20 |
+
def is_complete(self) -> bool:
|
| 21 |
+
"""True if shards cover a contiguous range starting at 0."""
|
| 22 |
+
if not self.shards:
|
| 23 |
+
return False
|
| 24 |
+
sorted_shards = sorted(self.shards, key=lambda s: s.layer_lo)
|
| 25 |
+
if sorted_shards[0].layer_lo != 0:
|
| 26 |
+
return False
|
| 27 |
+
expected_next = 0
|
| 28 |
+
for shard in sorted_shards:
|
| 29 |
+
if shard.layer_lo != expected_next:
|
| 30 |
+
return False
|
| 31 |
+
expected_next = shard.layer_hi + 1
|
| 32 |
+
return True
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class PipelineOrchestrator:
|
| 36 |
+
"""Constructs and executes layer pipelines across multiple shard servers.
|
| 37 |
+
|
| 38 |
+
Experimental Phase 3 feature. Not production-ready.
|
| 39 |
+
The orchestrator:
|
| 40 |
+
1. Finds available shards from the capability bus
|
| 41 |
+
2. Plans a pipeline covering all layers
|
| 42 |
+
3. Chains forward passes across shards
|
| 43 |
+
4. Streams token output back to the caller
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
def __init__(self, bus=None) -> None:
|
| 47 |
+
self._bus = bus
|
| 48 |
+
self._pipelines: dict[str, Pipeline] = {}
|
| 49 |
+
|
| 50 |
+
def plan(self, model_id: str, available_shards: list[ShardDescriptor]) -> Pipeline | None:
|
| 51 |
+
"""Choose a minimal set of shards that covers layers 0..N continuously."""
|
| 52 |
+
import uuid
|
| 53 |
+
model_shards = [s for s in available_shards if s.model_id == model_id]
|
| 54 |
+
if not model_shards:
|
| 55 |
+
return None
|
| 56 |
+
|
| 57 |
+
# Greedy cover: sort by layer_lo, pick first shard that starts where we left off
|
| 58 |
+
sorted_shards = sorted(model_shards, key=lambda s: s.layer_lo)
|
| 59 |
+
chosen: list[ShardDescriptor] = []
|
| 60 |
+
expected = 0
|
| 61 |
+
for shard in sorted_shards:
|
| 62 |
+
if shard.layer_lo == expected:
|
| 63 |
+
chosen.append(shard)
|
| 64 |
+
expected = shard.layer_hi + 1
|
| 65 |
+
|
| 66 |
+
pipeline = Pipeline(
|
| 67 |
+
pipeline_id=str(uuid.uuid4()),
|
| 68 |
+
model_id=model_id,
|
| 69 |
+
shards=chosen,
|
| 70 |
+
)
|
| 71 |
+
if pipeline.is_complete:
|
| 72 |
+
self._pipelines[pipeline.pipeline_id] = pipeline
|
| 73 |
+
return pipeline
|
| 74 |
+
return None
|
| 75 |
+
|
| 76 |
+
async def run(self, pipeline_id: str, prompt_tokens: list[int]) -> list[int]:
|
| 77 |
+
"""Execute a pipeline. Returns generated tokens.
|
| 78 |
+
|
| 79 |
+
Experimental — raises NotImplementedError in current state.
|
| 80 |
+
"""
|
| 81 |
+
raise NotImplementedError(
|
| 82 |
+
"PipelineOrchestrator.run() is not yet implemented. "
|
| 83 |
+
"This is an experimental Phase 3 feature (M26). "
|
| 84 |
+
"Enable with config.research.distributed_inference = True."
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
def list_pipelines(self) -> list[dict]:
|
| 88 |
+
return [
|
| 89 |
+
{
|
| 90 |
+
"pipeline_id": p.pipeline_id,
|
| 91 |
+
"model_id": p.model_id,
|
| 92 |
+
"shard_count": len(p.shards),
|
| 93 |
+
"complete": p.is_complete,
|
| 94 |
+
"status": p.status,
|
| 95 |
+
}
|
| 96 |
+
for p in self._pipelines.values()
|
| 97 |
+
]
|
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shard descriptors and server for distributed inference (M26 — experimental)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import time
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
ShardID = str
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass(frozen=True)
|
| 13 |
+
class ShardDescriptor:
|
| 14 |
+
"""Describes one contiguous layer range hosted by a node."""
|
| 15 |
+
shard_id: ShardID # "<model_id>:<lo>-<hi>"
|
| 16 |
+
model_id: str
|
| 17 |
+
layer_lo: int
|
| 18 |
+
layer_hi: int # inclusive
|
| 19 |
+
node_id: str
|
| 20 |
+
endpoint: str
|
| 21 |
+
dtype: str = "float16" # "float16" | "bfloat16" | "int8"
|
| 22 |
+
advertised_at: float = field(default_factory=time.time)
|
| 23 |
+
|
| 24 |
+
@property
|
| 25 |
+
def layer_count(self) -> int:
|
| 26 |
+
return self.layer_hi - self.layer_lo + 1
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class ShardServer:
|
| 30 |
+
"""Hosts one contiguous shard.
|
| 31 |
+
|
| 32 |
+
Loaded lazily on first use; evictable under memory pressure.
|
| 33 |
+
This is an experimental module — only active when
|
| 34 |
+
config.research.distributed_inference = True.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
def __init__(self, descriptor: ShardDescriptor) -> None:
|
| 38 |
+
self._desc = descriptor
|
| 39 |
+
self._model: Any = None
|
| 40 |
+
self._loaded = False
|
| 41 |
+
|
| 42 |
+
@property
|
| 43 |
+
def descriptor(self) -> ShardDescriptor:
|
| 44 |
+
return self._desc
|
| 45 |
+
|
| 46 |
+
def is_loaded(self) -> bool:
|
| 47 |
+
return self._loaded
|
| 48 |
+
|
| 49 |
+
def load(self) -> None:
|
| 50 |
+
"""Load the shard weights. Raises ImportError if torch unavailable."""
|
| 51 |
+
try:
|
| 52 |
+
import torch # noqa: F401
|
| 53 |
+
except ImportError as exc:
|
| 54 |
+
raise ImportError(
|
| 55 |
+
"PyTorch is required for distributed inference. "
|
| 56 |
+
"Install: pip install torch"
|
| 57 |
+
) from exc
|
| 58 |
+
# Actual weight loading would go here; placeholder for the research prototype.
|
| 59 |
+
self._loaded = True
|
| 60 |
+
|
| 61 |
+
def evict(self) -> None:
|
| 62 |
+
"""Free shard memory."""
|
| 63 |
+
self._model = None
|
| 64 |
+
self._loaded = False
|
| 65 |
+
|
| 66 |
+
async def forward(self, hidden_states: bytes, dtype: str = "float16") -> bytes:
|
| 67 |
+
"""Run one forward pass through this shard.
|
| 68 |
+
|
| 69 |
+
hidden_states: raw tensor bytes (X08 tensor-transport format)
|
| 70 |
+
Returns: raw output tensor bytes
|
| 71 |
+
"""
|
| 72 |
+
if not self._loaded:
|
| 73 |
+
self.load()
|
| 74 |
+
# Placeholder — real implementation uses torch to slice model and forward.
|
| 75 |
+
raise NotImplementedError(
|
| 76 |
+
"ShardServer.forward() is not yet implemented for this shard. "
|
| 77 |
+
"This is an experimental Phase 3 feature."
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
def health(self) -> dict:
|
| 81 |
+
return {
|
| 82 |
+
"shard_id": self._desc.shard_id,
|
| 83 |
+
"loaded": self._loaded,
|
| 84 |
+
"layers": f"{self._desc.layer_lo}-{self._desc.layer_hi}",
|
| 85 |
+
"status": "loaded" if self._loaded else "unloaded",
|
| 86 |
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""M30 — Evidence Graph package (experimental, Phase 3)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from hearthnet.evidence.store import Claim, ClaimID, ClaimSource, ClaimStore, Attestation, Dispute
|
| 5 |
+
|
| 6 |
+
__all__ = ["Claim", "ClaimID", "ClaimSource", "ClaimStore", "Attestation", "Dispute"]
|
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""M30 — Evidence Graph & EBKH Integration (experimental, Phase 3).
|
| 2 |
+
|
| 3 |
+
Content-addressed claim graph alongside the event log.
|
| 4 |
+
Events record what happened; claims record what is believed and by whom.
|
| 5 |
+
Gated by config.research.evidence_graph = True.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import hashlib
|
| 10 |
+
import json
|
| 11 |
+
import time
|
| 12 |
+
import uuid
|
| 13 |
+
from dataclasses import dataclass, field
|
| 14 |
+
from typing import Any, NewType
|
| 15 |
+
|
| 16 |
+
ClaimID = NewType("ClaimID", str)
|
| 17 |
+
SourceID = NewType("SourceID", str)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass(frozen=True)
|
| 21 |
+
class ClaimSource:
|
| 22 |
+
source_id: SourceID
|
| 23 |
+
source_type: str # "event" | "external" | "ebkh" | "manual"
|
| 24 |
+
url: str | None = None
|
| 25 |
+
retrieved_at: float | None = None
|
| 26 |
+
reliability_score: float = 1.0
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass(frozen=True)
|
| 30 |
+
class Claim:
|
| 31 |
+
"""An assertion by a node about some fact, with provenance."""
|
| 32 |
+
claim_id: ClaimID
|
| 33 |
+
subject: str # what the claim is about (URI or free text)
|
| 34 |
+
predicate: str # what is being claimed
|
| 35 |
+
object_: str # the claimed value
|
| 36 |
+
asserted_by: str # NodeID of the asserting node
|
| 37 |
+
sources: tuple[ClaimSource, ...]
|
| 38 |
+
community_id: str
|
| 39 |
+
asserted_at: float = field(default_factory=time.time)
|
| 40 |
+
confidence: float = 1.0
|
| 41 |
+
signature: bytes = b""
|
| 42 |
+
|
| 43 |
+
def content_id(self) -> ClaimID:
|
| 44 |
+
"""Stable content-addressed ID based on subject/predicate/object."""
|
| 45 |
+
payload = f"{self.subject}\x00{self.predicate}\x00{self.object_}"
|
| 46 |
+
return ClaimID("claim:" + hashlib.sha256(payload.encode()).hexdigest()[:16])
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@dataclass(frozen=True)
|
| 50 |
+
class Attestation:
|
| 51 |
+
"""A second node vouches for a claim."""
|
| 52 |
+
claim_id: ClaimID
|
| 53 |
+
attested_by: str
|
| 54 |
+
attested_at: float = field(default_factory=time.time)
|
| 55 |
+
signature: bytes = b""
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@dataclass(frozen=True)
|
| 59 |
+
class Dispute:
|
| 60 |
+
"""A node disputes a claim."""
|
| 61 |
+
claim_id: ClaimID
|
| 62 |
+
disputed_by: str
|
| 63 |
+
reason: str
|
| 64 |
+
disputed_at: float = field(default_factory=time.time)
|
| 65 |
+
counter_claim_id: ClaimID | None = None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class ClaimStore:
|
| 69 |
+
"""Append-only content-addressed claim store (in-memory prototype).
|
| 70 |
+
|
| 71 |
+
Production implementation will use a Merkle-DAG store backed by SQLite.
|
| 72 |
+
EBKH adapter (PostGIS + OSINT) plugs in via the `import_ebkh_record` method.
|
| 73 |
+
"""
|
| 74 |
+
|
| 75 |
+
def __init__(self) -> None:
|
| 76 |
+
self._claims: dict[ClaimID, Claim] = {}
|
| 77 |
+
self._attestations: dict[ClaimID, list[Attestation]] = {}
|
| 78 |
+
self._disputes: dict[ClaimID, list[Dispute]] = {}
|
| 79 |
+
|
| 80 |
+
def add_claim(self, claim: Claim) -> ClaimID:
|
| 81 |
+
cid = claim.content_id()
|
| 82 |
+
if cid not in self._claims:
|
| 83 |
+
self._claims[cid] = claim
|
| 84 |
+
return cid
|
| 85 |
+
|
| 86 |
+
def attest(self, attestation: Attestation) -> None:
|
| 87 |
+
self._attestations.setdefault(attestation.claim_id, []).append(attestation)
|
| 88 |
+
|
| 89 |
+
def dispute(self, dispute: Dispute) -> None:
|
| 90 |
+
self._disputes.setdefault(dispute.claim_id, []).append(dispute)
|
| 91 |
+
|
| 92 |
+
def get_claim(self, claim_id: ClaimID) -> Claim | None:
|
| 93 |
+
return self._claims.get(claim_id)
|
| 94 |
+
|
| 95 |
+
def find_by_subject(self, subject: str) -> list[Claim]:
|
| 96 |
+
return [c for c in self._claims.values() if c.subject == subject]
|
| 97 |
+
|
| 98 |
+
def attestation_count(self, claim_id: ClaimID) -> int:
|
| 99 |
+
return len(self._attestations.get(claim_id, []))
|
| 100 |
+
|
| 101 |
+
def is_disputed(self, claim_id: ClaimID) -> bool:
|
| 102 |
+
return bool(self._disputes.get(claim_id))
|
| 103 |
+
|
| 104 |
+
def import_ebkh_record(self, record: dict[str, Any], asserted_by: str, community_id: str) -> ClaimID:
|
| 105 |
+
"""Import a record from Christof's EBKH system as a Claim.
|
| 106 |
+
|
| 107 |
+
Expects record to have at minimum: subject, predicate, object, source_url.
|
| 108 |
+
"""
|
| 109 |
+
source = ClaimSource(
|
| 110 |
+
source_id=SourceID(record.get("ebkh_id", str(uuid.uuid4()))),
|
| 111 |
+
source_type="ebkh",
|
| 112 |
+
url=record.get("source_url"),
|
| 113 |
+
reliability_score=float(record.get("reliability", 1.0)),
|
| 114 |
+
)
|
| 115 |
+
claim = Claim(
|
| 116 |
+
claim_id=ClaimID(str(uuid.uuid4())),
|
| 117 |
+
subject=str(record.get("subject", "")),
|
| 118 |
+
predicate=str(record.get("predicate", "asserts")),
|
| 119 |
+
object_=str(record.get("object", "")),
|
| 120 |
+
asserted_by=asserted_by,
|
| 121 |
+
sources=(source,),
|
| 122 |
+
community_id=community_id,
|
| 123 |
+
)
|
| 124 |
+
return self.add_claim(claim)
|
| 125 |
+
|
| 126 |
+
def summary(self) -> dict:
|
| 127 |
+
return {
|
| 128 |
+
"claims": len(self._claims),
|
| 129 |
+
"attestations": sum(len(v) for v in self._attestations.values()),
|
| 130 |
+
"disputes": sum(len(v) for v in self._disputes.values()),
|
| 131 |
+
}
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Federation manifest builder and verifier (M14)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import base64
|
| 5 |
+
import json
|
| 6 |
+
import time
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
try:
|
| 11 |
+
import nacl.bindings
|
| 12 |
+
import nacl.exceptions
|
| 13 |
+
|
| 14 |
+
_NACL_AVAILABLE = True
|
| 15 |
+
except ImportError: # pragma: no cover
|
| 16 |
+
nacl = None # type: ignore[assignment]
|
| 17 |
+
_NACL_AVAILABLE = False
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ManifestError(Exception):
|
| 21 |
+
"""Raised for federation manifest validation failures."""
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
# Value types
|
| 26 |
+
# ---------------------------------------------------------------------------
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass(frozen=True)
|
| 30 |
+
class FederationScope:
|
| 31 |
+
"""What one community grants the other."""
|
| 32 |
+
|
| 33 |
+
capabilities: list[str]
|
| 34 |
+
data_visibility: str = "public_corpora_only" # "public_corpora_only"|"members_only"|"open"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass(frozen=True)
|
| 38 |
+
class FederationManifest:
|
| 39 |
+
"""A bilateral federation agreement between two communities."""
|
| 40 |
+
|
| 41 |
+
schema_version: int
|
| 42 |
+
federation_id: str
|
| 43 |
+
community_a_id: str
|
| 44 |
+
community_a_name: str
|
| 45 |
+
community_b_id: str
|
| 46 |
+
community_b_name: str
|
| 47 |
+
scope_a_to_b: FederationScope # what A grants B
|
| 48 |
+
scope_b_to_a: FederationScope # what B grants A
|
| 49 |
+
sig_a: str # Ed25519 sig from anchor of community A
|
| 50 |
+
sig_b: str # Ed25519 sig from anchor of community B
|
| 51 |
+
co_signers_a: list[str] # additional anchor signatures from community A
|
| 52 |
+
co_signers_b: list[str] # additional anchor signatures from community B
|
| 53 |
+
created_at: int # unix seconds
|
| 54 |
+
expires_at: int # unix seconds
|
| 55 |
+
bootstrap_endpoints_a: list[str]
|
| 56 |
+
bootstrap_endpoints_b: list[str]
|
| 57 |
+
|
| 58 |
+
def is_expired(self, now: int | None = None) -> bool:
|
| 59 |
+
ts = now if now is not None else int(time.time())
|
| 60 |
+
return ts >= self.expires_at
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@dataclass(frozen=True)
|
| 64 |
+
class FederationProposal:
|
| 65 |
+
"""A draft federation proposal from community A to community B."""
|
| 66 |
+
|
| 67 |
+
community_a: str # community_id of proposer
|
| 68 |
+
community_b: str # community_id of target
|
| 69 |
+
scope_a: FederationScope # scope A proposes to grant B
|
| 70 |
+
scope_b: FederationScope # scope A requests from B
|
| 71 |
+
bootstrap_a: list[str] # endpoints for community A
|
| 72 |
+
bootstrap_b: list[str] # expected endpoints for community B
|
| 73 |
+
proposed_at: int # unix seconds
|
| 74 |
+
proposer_sig: str # Ed25519 sig over the proposal body by an anchor of A
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ---------------------------------------------------------------------------
|
| 78 |
+
# Encoding helpers
|
| 79 |
+
# ---------------------------------------------------------------------------
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _b64url_encode(b: bytes) -> str:
|
| 83 |
+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _b64url_decode(s: str) -> bytes:
|
| 87 |
+
pad = 4 - len(s) % 4
|
| 88 |
+
if pad != 4:
|
| 89 |
+
s += "=" * pad
|
| 90 |
+
return base64.urlsafe_b64decode(s)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _scope_to_dict(s: FederationScope) -> dict:
|
| 94 |
+
return {"capabilities": list(s.capabilities), "data_visibility": s.data_visibility}
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _scope_from_dict(d: dict) -> FederationScope:
|
| 98 |
+
return FederationScope(
|
| 99 |
+
capabilities=list(d.get("capabilities", [])),
|
| 100 |
+
data_visibility=d.get("data_visibility", "public_corpora_only"),
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _proposal_body(proposal: FederationProposal) -> bytes:
|
| 105 |
+
"""Canonical bytes for signing a proposal."""
|
| 106 |
+
body = {
|
| 107 |
+
"community_a": proposal.community_a,
|
| 108 |
+
"community_b": proposal.community_b,
|
| 109 |
+
"scope_a": _scope_to_dict(proposal.scope_a),
|
| 110 |
+
"scope_b": _scope_to_dict(proposal.scope_b),
|
| 111 |
+
"bootstrap_a": proposal.bootstrap_a,
|
| 112 |
+
"bootstrap_b": proposal.bootstrap_b,
|
| 113 |
+
"proposed_at": proposal.proposed_at,
|
| 114 |
+
}
|
| 115 |
+
return json.dumps(body, sort_keys=True, separators=(",", ":")).encode()
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _manifest_body(manifest: FederationManifest) -> bytes:
|
| 119 |
+
"""Canonical bytes for signing a manifest (excludes sig_a, sig_b, co_signers)."""
|
| 120 |
+
body = {
|
| 121 |
+
"schema_version": manifest.schema_version,
|
| 122 |
+
"federation_id": manifest.federation_id,
|
| 123 |
+
"community_a_id": manifest.community_a_id,
|
| 124 |
+
"community_a_name": manifest.community_a_name,
|
| 125 |
+
"community_b_id": manifest.community_b_id,
|
| 126 |
+
"community_b_name": manifest.community_b_name,
|
| 127 |
+
"scope_a_to_b": _scope_to_dict(manifest.scope_a_to_b),
|
| 128 |
+
"scope_b_to_a": _scope_to_dict(manifest.scope_b_to_a),
|
| 129 |
+
"created_at": manifest.created_at,
|
| 130 |
+
"expires_at": manifest.expires_at,
|
| 131 |
+
"bootstrap_endpoints_a": manifest.bootstrap_endpoints_a,
|
| 132 |
+
"bootstrap_endpoints_b": manifest.bootstrap_endpoints_b,
|
| 133 |
+
}
|
| 134 |
+
return json.dumps(body, sort_keys=True, separators=(",", ":")).encode()
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# ---------------------------------------------------------------------------
|
| 138 |
+
# Build
|
| 139 |
+
# ---------------------------------------------------------------------------
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def build_federation_proposal(
|
| 143 |
+
our_manifest: Any,
|
| 144 |
+
our_keypair: Any,
|
| 145 |
+
their_community_id: str,
|
| 146 |
+
their_community_name: str,
|
| 147 |
+
scope_we_grant: FederationScope,
|
| 148 |
+
scope_they_grant: FederationScope,
|
| 149 |
+
bootstrap_endpoints: list[str],
|
| 150 |
+
) -> FederationProposal:
|
| 151 |
+
"""Create a signed federation proposal to send to a peer community."""
|
| 152 |
+
if not _NACL_AVAILABLE:
|
| 153 |
+
raise ManifestError("PyNaCl is required for federation. Install pynacl.")
|
| 154 |
+
|
| 155 |
+
our_community_id = getattr(our_manifest, "community_id", "")
|
| 156 |
+
now = int(time.time())
|
| 157 |
+
|
| 158 |
+
# Build an unsigned proposal first to produce the body bytes for signing
|
| 159 |
+
unsigned_proposal = FederationProposal(
|
| 160 |
+
community_a=our_community_id,
|
| 161 |
+
community_b=their_community_id,
|
| 162 |
+
scope_a=scope_we_grant,
|
| 163 |
+
scope_b=scope_they_grant,
|
| 164 |
+
bootstrap_a=bootstrap_endpoints,
|
| 165 |
+
bootstrap_b=[],
|
| 166 |
+
proposed_at=now,
|
| 167 |
+
proposer_sig="",
|
| 168 |
+
)
|
| 169 |
+
body = _proposal_body(unsigned_proposal)
|
| 170 |
+
try:
|
| 171 |
+
signed = our_keypair.signing_key.sign(body)
|
| 172 |
+
sig_b64 = _b64url_encode(signed.signature)
|
| 173 |
+
except Exception as exc:
|
| 174 |
+
raise ManifestError(f"Signing proposal failed: {exc}") from exc
|
| 175 |
+
|
| 176 |
+
return FederationProposal(
|
| 177 |
+
community_a=our_community_id,
|
| 178 |
+
community_b=their_community_id,
|
| 179 |
+
scope_a=scope_we_grant,
|
| 180 |
+
scope_b=scope_they_grant,
|
| 181 |
+
bootstrap_a=bootstrap_endpoints,
|
| 182 |
+
bootstrap_b=[],
|
| 183 |
+
proposed_at=now,
|
| 184 |
+
proposer_sig=sig_b64,
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def co_sign_federation(proposal: FederationProposal, keypair: Any, role: str) -> dict:
|
| 189 |
+
"""Co-sign a federation proposal on behalf of a community anchor.
|
| 190 |
+
|
| 191 |
+
Returns {signed_by: node_id, signature: b64url, role: str}.
|
| 192 |
+
"""
|
| 193 |
+
if not _NACL_AVAILABLE:
|
| 194 |
+
raise ManifestError("PyNaCl is required. Install pynacl.")
|
| 195 |
+
|
| 196 |
+
from hearthnet.identity.keys import full_node_id
|
| 197 |
+
|
| 198 |
+
body = _proposal_body(proposal)
|
| 199 |
+
try:
|
| 200 |
+
signed = keypair.signing_key.sign(body)
|
| 201 |
+
sig_b64 = _b64url_encode(signed.signature)
|
| 202 |
+
except Exception as exc:
|
| 203 |
+
raise ManifestError(f"Co-signing failed: {exc}") from exc
|
| 204 |
+
|
| 205 |
+
node_id = full_node_id(bytes(keypair.verify_key))
|
| 206 |
+
return {"signed_by": node_id, "signature": sig_b64, "role": role}
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def finalize_federation_manifest(
|
| 210 |
+
proposal: FederationProposal,
|
| 211 |
+
sig_a: str,
|
| 212 |
+
sig_b: str,
|
| 213 |
+
community_a_name: str = "",
|
| 214 |
+
community_b_name: str = "",
|
| 215 |
+
ttl_seconds: int = 365 * 24 * 3600,
|
| 216 |
+
) -> FederationManifest:
|
| 217 |
+
"""Combine a proposal and both anchor signatures into a finalized manifest."""
|
| 218 |
+
from hearthnet.events.types import new_ulid
|
| 219 |
+
|
| 220 |
+
now = int(time.time())
|
| 221 |
+
return FederationManifest(
|
| 222 |
+
schema_version=1,
|
| 223 |
+
federation_id=new_ulid(),
|
| 224 |
+
community_a_id=proposal.community_a,
|
| 225 |
+
community_a_name=community_a_name,
|
| 226 |
+
community_b_id=proposal.community_b,
|
| 227 |
+
community_b_name=community_b_name,
|
| 228 |
+
scope_a_to_b=proposal.scope_a,
|
| 229 |
+
scope_b_to_a=proposal.scope_b,
|
| 230 |
+
sig_a=sig_a,
|
| 231 |
+
sig_b=sig_b,
|
| 232 |
+
co_signers_a=[],
|
| 233 |
+
co_signers_b=[],
|
| 234 |
+
created_at=now,
|
| 235 |
+
expires_at=now + ttl_seconds,
|
| 236 |
+
bootstrap_endpoints_a=proposal.bootstrap_a,
|
| 237 |
+
bootstrap_endpoints_b=proposal.bootstrap_b,
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def verify_federation_manifest(
|
| 242 |
+
manifest: FederationManifest,
|
| 243 |
+
community_a_verify_key: Any,
|
| 244 |
+
community_b_verify_key: Any,
|
| 245 |
+
) -> None:
|
| 246 |
+
"""Verify both anchor signatures on a manifest. Raises ManifestError if invalid."""
|
| 247 |
+
if not _NACL_AVAILABLE:
|
| 248 |
+
raise ManifestError("PyNaCl is required. Install pynacl.")
|
| 249 |
+
|
| 250 |
+
body = _manifest_body(manifest)
|
| 251 |
+
|
| 252 |
+
for label, sig_str, vk in [
|
| 253 |
+
("community_a", manifest.sig_a, community_a_verify_key),
|
| 254 |
+
("community_b", manifest.sig_b, community_b_verify_key),
|
| 255 |
+
]:
|
| 256 |
+
if not sig_str:
|
| 257 |
+
raise ManifestError(f"Missing signature for {label}")
|
| 258 |
+
try:
|
| 259 |
+
import nacl.exceptions
|
| 260 |
+
|
| 261 |
+
sig_bytes = _b64url_decode(sig_str)
|
| 262 |
+
vk.verify(body, sig_bytes)
|
| 263 |
+
except nacl.exceptions.BadSignatureError as exc:
|
| 264 |
+
raise ManifestError(f"Invalid signature for {label}: {exc}") from exc
|
| 265 |
+
except Exception as exc:
|
| 266 |
+
raise ManifestError(f"Signature verification error for {label}: {exc}") from exc
|
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cross-community peering store and HTTP client (M14)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import sqlite3
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from hearthnet.federation.manifest import (
|
| 10 |
+
FederationManifest,
|
| 11 |
+
FederationProposal,
|
| 12 |
+
ManifestError,
|
| 13 |
+
_scope_from_dict,
|
| 14 |
+
_scope_to_dict,
|
| 15 |
+
co_sign_federation,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
# ---------------------------------------------------------------------------
|
| 19 |
+
# Serialisation helpers
|
| 20 |
+
# ---------------------------------------------------------------------------
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _manifest_to_dict(m: FederationManifest) -> dict:
|
| 24 |
+
return {
|
| 25 |
+
"schema_version": m.schema_version,
|
| 26 |
+
"federation_id": m.federation_id,
|
| 27 |
+
"community_a_id": m.community_a_id,
|
| 28 |
+
"community_a_name": m.community_a_name,
|
| 29 |
+
"community_b_id": m.community_b_id,
|
| 30 |
+
"community_b_name": m.community_b_name,
|
| 31 |
+
"scope_a_to_b": _scope_to_dict(m.scope_a_to_b),
|
| 32 |
+
"scope_b_to_a": _scope_to_dict(m.scope_b_to_a),
|
| 33 |
+
"sig_a": m.sig_a,
|
| 34 |
+
"sig_b": m.sig_b,
|
| 35 |
+
"co_signers_a": m.co_signers_a,
|
| 36 |
+
"co_signers_b": m.co_signers_b,
|
| 37 |
+
"created_at": m.created_at,
|
| 38 |
+
"expires_at": m.expires_at,
|
| 39 |
+
"bootstrap_endpoints_a": m.bootstrap_endpoints_a,
|
| 40 |
+
"bootstrap_endpoints_b": m.bootstrap_endpoints_b,
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _manifest_from_dict(d: dict) -> FederationManifest:
|
| 45 |
+
return FederationManifest(
|
| 46 |
+
schema_version=int(d["schema_version"]),
|
| 47 |
+
federation_id=d["federation_id"],
|
| 48 |
+
community_a_id=d["community_a_id"],
|
| 49 |
+
community_a_name=d.get("community_a_name", ""),
|
| 50 |
+
community_b_id=d["community_b_id"],
|
| 51 |
+
community_b_name=d.get("community_b_name", ""),
|
| 52 |
+
scope_a_to_b=_scope_from_dict(d["scope_a_to_b"]),
|
| 53 |
+
scope_b_to_a=_scope_from_dict(d["scope_b_to_a"]),
|
| 54 |
+
sig_a=d.get("sig_a", ""),
|
| 55 |
+
sig_b=d.get("sig_b", ""),
|
| 56 |
+
co_signers_a=list(d.get("co_signers_a", [])),
|
| 57 |
+
co_signers_b=list(d.get("co_signers_b", [])),
|
| 58 |
+
created_at=int(d["created_at"]),
|
| 59 |
+
expires_at=int(d["expires_at"]),
|
| 60 |
+
bootstrap_endpoints_a=list(d.get("bootstrap_endpoints_a", [])),
|
| 61 |
+
bootstrap_endpoints_b=list(d.get("bootstrap_endpoints_b", [])),
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _proposal_to_dict(p: FederationProposal) -> dict:
|
| 66 |
+
return {
|
| 67 |
+
"community_a": p.community_a,
|
| 68 |
+
"community_b": p.community_b,
|
| 69 |
+
"scope_a": _scope_to_dict(p.scope_a),
|
| 70 |
+
"scope_b": _scope_to_dict(p.scope_b),
|
| 71 |
+
"bootstrap_a": p.bootstrap_a,
|
| 72 |
+
"bootstrap_b": p.bootstrap_b,
|
| 73 |
+
"proposed_at": p.proposed_at,
|
| 74 |
+
"proposer_sig": p.proposer_sig,
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _proposal_from_dict(d: dict) -> FederationProposal:
|
| 79 |
+
return FederationProposal(
|
| 80 |
+
community_a=d["community_a"],
|
| 81 |
+
community_b=d["community_b"],
|
| 82 |
+
scope_a=_scope_from_dict(d["scope_a"]),
|
| 83 |
+
scope_b=_scope_from_dict(d["scope_b"]),
|
| 84 |
+
bootstrap_a=list(d.get("bootstrap_a", [])),
|
| 85 |
+
bootstrap_b=list(d.get("bootstrap_b", [])),
|
| 86 |
+
proposed_at=int(d["proposed_at"]),
|
| 87 |
+
proposer_sig=d.get("proposer_sig", ""),
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# ---------------------------------------------------------------------------
|
| 92 |
+
# FederationStore (SQLite)
|
| 93 |
+
# ---------------------------------------------------------------------------
|
| 94 |
+
|
| 95 |
+
_SCHEMA = """
|
| 96 |
+
CREATE TABLE IF NOT EXISTS federation_manifests (
|
| 97 |
+
federation_id TEXT PRIMARY KEY,
|
| 98 |
+
community_id TEXT NOT NULL,
|
| 99 |
+
manifest_json TEXT NOT NULL,
|
| 100 |
+
created_at INTEGER NOT NULL,
|
| 101 |
+
expires_at INTEGER NOT NULL
|
| 102 |
+
);
|
| 103 |
+
CREATE INDEX IF NOT EXISTS idx_fed_community ON federation_manifests (community_id);
|
| 104 |
+
"""
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class FederationStore:
|
| 108 |
+
"""SQLite-backed store for active federation manifests."""
|
| 109 |
+
|
| 110 |
+
def __init__(self, db_path: str | Path = ":memory:") -> None:
|
| 111 |
+
self._db_path = str(db_path)
|
| 112 |
+
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
| 113 |
+
self._conn.executescript(_SCHEMA)
|
| 114 |
+
self._conn.commit()
|
| 115 |
+
|
| 116 |
+
def close(self) -> None:
|
| 117 |
+
self._conn.close()
|
| 118 |
+
|
| 119 |
+
def add_manifest(self, m: FederationManifest) -> None:
|
| 120 |
+
"""Insert or replace a federation manifest."""
|
| 121 |
+
data = json.dumps(_manifest_to_dict(m))
|
| 122 |
+
with self._conn:
|
| 123 |
+
self._conn.execute(
|
| 124 |
+
"INSERT OR REPLACE INTO federation_manifests "
|
| 125 |
+
"(federation_id, community_id, manifest_json, created_at, expires_at) "
|
| 126 |
+
"VALUES (?, ?, ?, ?, ?)",
|
| 127 |
+
(m.federation_id, m.community_b_id, data, m.created_at, m.expires_at),
|
| 128 |
+
)
|
| 129 |
+
# Also index by community_a so lookups from either side work
|
| 130 |
+
self._conn.execute(
|
| 131 |
+
"INSERT OR REPLACE INTO federation_manifests "
|
| 132 |
+
"(federation_id, community_id, manifest_json, created_at, expires_at) "
|
| 133 |
+
"VALUES (?, ?, ?, ?, ?)",
|
| 134 |
+
(
|
| 135 |
+
m.federation_id + "_rev",
|
| 136 |
+
m.community_a_id,
|
| 137 |
+
data,
|
| 138 |
+
m.created_at,
|
| 139 |
+
m.expires_at,
|
| 140 |
+
),
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
def get_manifest(self, community_id: str) -> FederationManifest | None:
|
| 144 |
+
"""Return the most recent active manifest for a community, or None."""
|
| 145 |
+
import time
|
| 146 |
+
|
| 147 |
+
now = int(time.time())
|
| 148 |
+
row = self._conn.execute(
|
| 149 |
+
"SELECT manifest_json FROM federation_manifests "
|
| 150 |
+
"WHERE community_id = ? AND expires_at > ? "
|
| 151 |
+
"ORDER BY created_at DESC LIMIT 1",
|
| 152 |
+
(community_id, now),
|
| 153 |
+
).fetchone()
|
| 154 |
+
if row is None:
|
| 155 |
+
return None
|
| 156 |
+
return _manifest_from_dict(json.loads(row[0]))
|
| 157 |
+
|
| 158 |
+
def list_active(self) -> list[FederationManifest]:
|
| 159 |
+
"""Return all non-expired manifests (deduplicated by federation_id)."""
|
| 160 |
+
import time
|
| 161 |
+
|
| 162 |
+
now = int(time.time())
|
| 163 |
+
rows = self._conn.execute(
|
| 164 |
+
"SELECT DISTINCT manifest_json FROM federation_manifests WHERE expires_at > ?",
|
| 165 |
+
(now,),
|
| 166 |
+
).fetchall()
|
| 167 |
+
seen: set[str] = set()
|
| 168 |
+
result: list[FederationManifest] = []
|
| 169 |
+
for (row,) in rows:
|
| 170 |
+
m = _manifest_from_dict(json.loads(row))
|
| 171 |
+
if m.federation_id not in seen:
|
| 172 |
+
seen.add(m.federation_id)
|
| 173 |
+
result.append(m)
|
| 174 |
+
return result
|
| 175 |
+
|
| 176 |
+
def remove(self, federation_id: str) -> None:
|
| 177 |
+
"""Remove a federation manifest by its ID."""
|
| 178 |
+
with self._conn:
|
| 179 |
+
self._conn.execute(
|
| 180 |
+
"DELETE FROM federation_manifests WHERE federation_id = ? OR federation_id = ?",
|
| 181 |
+
(federation_id, federation_id + "_rev"),
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# ---------------------------------------------------------------------------
|
| 186 |
+
# PeeringClient — HTTP handshake helpers
|
| 187 |
+
# ---------------------------------------------------------------------------
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
class PeeringClient:
|
| 191 |
+
"""HTTP client for cross-community federation handshake.
|
| 192 |
+
|
| 193 |
+
Uses the injected http_client which must implement .post(url, json=...) -> dict.
|
| 194 |
+
"""
|
| 195 |
+
|
| 196 |
+
def __init__(self, http_client: Any) -> None:
|
| 197 |
+
self._http = http_client
|
| 198 |
+
|
| 199 |
+
def propose(self, remote_url: str, proposal: FederationProposal) -> FederationProposal:
|
| 200 |
+
"""Send a federation proposal to a peer community.
|
| 201 |
+
|
| 202 |
+
Returns the peer's (possibly updated) proposal echo or raises ManifestError.
|
| 203 |
+
"""
|
| 204 |
+
endpoint = remote_url.rstrip("/") + "/federation/propose"
|
| 205 |
+
body = _proposal_to_dict(proposal)
|
| 206 |
+
try:
|
| 207 |
+
resp = self._http.post(endpoint, json=body)
|
| 208 |
+
except Exception as exc:
|
| 209 |
+
raise ManifestError(f"Peering propose failed: {exc}") from exc
|
| 210 |
+
if isinstance(resp, dict) and "error" in resp:
|
| 211 |
+
raise ManifestError(f"Remote rejected proposal: {resp['error']}")
|
| 212 |
+
return _proposal_from_dict(resp)
|
| 213 |
+
|
| 214 |
+
def co_sign(
|
| 215 |
+
self,
|
| 216 |
+
remote_url: str,
|
| 217 |
+
proposal: FederationProposal,
|
| 218 |
+
keypair: Any,
|
| 219 |
+
role: str,
|
| 220 |
+
) -> FederationManifest:
|
| 221 |
+
"""Co-sign a proposal and submit it to produce a finalized manifest."""
|
| 222 |
+
cosig = co_sign_federation(proposal, keypair, role)
|
| 223 |
+
endpoint = remote_url.rstrip("/") + "/federation/cosign"
|
| 224 |
+
body = {
|
| 225 |
+
"proposal": _proposal_to_dict(proposal),
|
| 226 |
+
"co_sig": cosig,
|
| 227 |
+
}
|
| 228 |
+
try:
|
| 229 |
+
resp = self._http.post(endpoint, json=body)
|
| 230 |
+
except Exception as exc:
|
| 231 |
+
raise ManifestError(f"Peering co-sign failed: {exc}") from exc
|
| 232 |
+
if isinstance(resp, dict) and "error" in resp:
|
| 233 |
+
raise ManifestError(f"Remote rejected co-sign: {resp['error']}")
|
| 234 |
+
return _manifest_from_dict(resp)
|
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FederationService — registers federation.* capabilities on the bus (M14)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from hearthnet.federation.manifest import (
|
| 7 |
+
FederationManifest,
|
| 8 |
+
ManifestError,
|
| 9 |
+
finalize_federation_manifest,
|
| 10 |
+
)
|
| 11 |
+
from hearthnet.federation.peering import (
|
| 12 |
+
FederationStore,
|
| 13 |
+
_proposal_from_dict,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class FederationService:
|
| 18 |
+
"""Manages bilateral community federation.
|
| 19 |
+
|
| 20 |
+
Registers:
|
| 21 |
+
federation.peer.list@1.0
|
| 22 |
+
federation.peer.add@1.0
|
| 23 |
+
federation.peer.remove@1.0
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
name = "federation"
|
| 27 |
+
|
| 28 |
+
def __init__(
|
| 29 |
+
self,
|
| 30 |
+
keypair: Any,
|
| 31 |
+
community_manifest: Any | None = None,
|
| 32 |
+
store: FederationStore | None = None,
|
| 33 |
+
bus: Any | None = None,
|
| 34 |
+
) -> None:
|
| 35 |
+
self._kp = keypair
|
| 36 |
+
self._community_manifest = community_manifest
|
| 37 |
+
self._store = store or FederationStore()
|
| 38 |
+
self._bus = bus
|
| 39 |
+
|
| 40 |
+
# ------------------------------------------------------------------
|
| 41 |
+
# Registration
|
| 42 |
+
# ------------------------------------------------------------------
|
| 43 |
+
|
| 44 |
+
def register(self, bus: Any) -> None:
|
| 45 |
+
"""Register all federation capabilities with the bus Registry."""
|
| 46 |
+
from hearthnet.bus.capability import CapabilityDescriptor
|
| 47 |
+
|
| 48 |
+
self._bus = bus
|
| 49 |
+
registry = getattr(bus, "registry", None)
|
| 50 |
+
if registry is None:
|
| 51 |
+
return
|
| 52 |
+
|
| 53 |
+
descriptors = [
|
| 54 |
+
("federation.peer.list", "1.0", self._handle_list),
|
| 55 |
+
("federation.peer.add", "1.0", self._handle_add),
|
| 56 |
+
("federation.peer.remove", "1.0", self._handle_remove),
|
| 57 |
+
]
|
| 58 |
+
for name, version, handler in descriptors:
|
| 59 |
+
desc = CapabilityDescriptor(
|
| 60 |
+
name=name,
|
| 61 |
+
version=version,
|
| 62 |
+
stability="stable",
|
| 63 |
+
params={},
|
| 64 |
+
max_concurrent=2,
|
| 65 |
+
)
|
| 66 |
+
registry.register_local(desc, handler)
|
| 67 |
+
|
| 68 |
+
# ------------------------------------------------------------------
|
| 69 |
+
# Handlers
|
| 70 |
+
# ------------------------------------------------------------------
|
| 71 |
+
|
| 72 |
+
def _handle_list(self, params: dict) -> dict:
|
| 73 |
+
"""federation.peer.list@1.0 — list active federation peers.
|
| 74 |
+
|
| 75 |
+
returns: {peers: list[{community_id, community_name, scope, expires_at}]}
|
| 76 |
+
"""
|
| 77 |
+
manifests = self._store.list_active()
|
| 78 |
+
our_community_id = getattr(self._community_manifest, "community_id", "")
|
| 79 |
+
peers = []
|
| 80 |
+
for m in manifests:
|
| 81 |
+
# Determine which side we are to pick the correct scope
|
| 82 |
+
if m.community_a_id == our_community_id:
|
| 83 |
+
peer_id = m.community_b_id
|
| 84 |
+
peer_name = m.community_b_name
|
| 85 |
+
scope = m.scope_b_to_a # scope they grant us
|
| 86 |
+
else:
|
| 87 |
+
peer_id = m.community_a_id
|
| 88 |
+
peer_name = m.community_a_name
|
| 89 |
+
scope = m.scope_a_to_b
|
| 90 |
+
peers.append({
|
| 91 |
+
"community_id": peer_id,
|
| 92 |
+
"community_name": peer_name,
|
| 93 |
+
"federation_id": m.federation_id,
|
| 94 |
+
"scope": {
|
| 95 |
+
"capabilities": list(scope.capabilities),
|
| 96 |
+
"data_visibility": scope.data_visibility,
|
| 97 |
+
},
|
| 98 |
+
"expires_at": m.expires_at,
|
| 99 |
+
})
|
| 100 |
+
return {"peers": peers}
|
| 101 |
+
|
| 102 |
+
def _handle_add(self, params: dict) -> dict:
|
| 103 |
+
"""federation.peer.add@1.0 — accept a signed proposal + co-sig and activate.
|
| 104 |
+
|
| 105 |
+
params: {proposal_json: str, co_sig_json: str,
|
| 106 |
+
community_a_name?: str, community_b_name?: str}
|
| 107 |
+
returns: {federation_id: str, active: bool}
|
| 108 |
+
"""
|
| 109 |
+
import json as _json
|
| 110 |
+
|
| 111 |
+
try:
|
| 112 |
+
proposal_dict = _json.loads(params.get("proposal_json", "{}"))
|
| 113 |
+
co_sig_dict = _json.loads(params.get("co_sig_json", "{}"))
|
| 114 |
+
except Exception as exc:
|
| 115 |
+
return {"error": f"JSON parse error: {exc}", "active": False, "federation_id": ""}
|
| 116 |
+
|
| 117 |
+
try:
|
| 118 |
+
proposal = _proposal_from_dict(proposal_dict)
|
| 119 |
+
sig_a = proposal.proposer_sig
|
| 120 |
+
sig_b = co_sig_dict.get("signature", "")
|
| 121 |
+
community_a_name = params.get("community_a_name", "")
|
| 122 |
+
community_b_name = params.get("community_b_name", "")
|
| 123 |
+
manifest = finalize_federation_manifest(
|
| 124 |
+
proposal,
|
| 125 |
+
sig_a=sig_a,
|
| 126 |
+
sig_b=sig_b,
|
| 127 |
+
community_a_name=community_a_name,
|
| 128 |
+
community_b_name=community_b_name,
|
| 129 |
+
)
|
| 130 |
+
self._store.add_manifest(manifest)
|
| 131 |
+
return {"federation_id": manifest.federation_id, "active": True}
|
| 132 |
+
except ManifestError as exc:
|
| 133 |
+
return {"error": str(exc), "active": False, "federation_id": ""}
|
| 134 |
+
|
| 135 |
+
def _handle_remove(self, params: dict) -> dict:
|
| 136 |
+
"""federation.peer.remove@1.0 — deactivate federation with a community.
|
| 137 |
+
|
| 138 |
+
params: {community_id: str}
|
| 139 |
+
returns: {removed: bool}
|
| 140 |
+
"""
|
| 141 |
+
community_id = params.get("community_id", "")
|
| 142 |
+
if not community_id:
|
| 143 |
+
return {"removed": False, "error": "community_id required"}
|
| 144 |
+
m = self._store.get_manifest(community_id)
|
| 145 |
+
if m is None:
|
| 146 |
+
return {"removed": False}
|
| 147 |
+
self._store.remove(m.federation_id)
|
| 148 |
+
return {"removed": True}
|
| 149 |
+
|
| 150 |
+
# ------------------------------------------------------------------
|
| 151 |
+
# Direct API
|
| 152 |
+
# ------------------------------------------------------------------
|
| 153 |
+
|
| 154 |
+
def add_manifest(self, manifest: FederationManifest) -> None:
|
| 155 |
+
"""Directly add a finalized manifest (bypasses the bus)."""
|
| 156 |
+
self._store.add_manifest(manifest)
|
| 157 |
+
|
| 158 |
+
def get_peer(self, community_id: str) -> FederationManifest | None:
|
| 159 |
+
"""Return the active manifest for a peer community, or None."""
|
| 160 |
+
return self._store.get_manifest(community_id)
|
| 161 |
+
|
| 162 |
+
def list_peers(self) -> list[FederationManifest]:
|
| 163 |
+
"""Return all active federation manifests."""
|
| 164 |
+
return self._store.list_active()
|
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""M28 — Federated Learning package (experimental, Phase 3)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from hearthnet.fedlearn.coordinator import FedLearnCoordinator, RoundManifest, ParticipantSubmission
|
| 5 |
+
|
| 6 |
+
__all__ = ["FedLearnCoordinator", "RoundManifest", "ParticipantSubmission"]
|
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""M28 — Federated Learning / LoRA aggregation (experimental, Phase 3).
|
| 2 |
+
|
| 3 |
+
FedAvg on LoRA adapter weight deltas. Each node trains locally;
|
| 4 |
+
only adapter deltas (not raw data or full weights) are shared.
|
| 5 |
+
Gated by config.research.federated_learning = True.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import time
|
| 10 |
+
import uuid
|
| 11 |
+
from dataclasses import dataclass, field
|
| 12 |
+
from typing import NewType
|
| 13 |
+
|
| 14 |
+
RoundID = NewType("RoundID", str)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass(frozen=True)
|
| 18 |
+
class RoundManifest:
|
| 19 |
+
"""Describes a federated learning round."""
|
| 20 |
+
round_id: RoundID
|
| 21 |
+
base_model_id: str
|
| 22 |
+
coordinator_node_id: str
|
| 23 |
+
community_id: str
|
| 24 |
+
lora_rank: int = 16
|
| 25 |
+
lora_alpha: float = 32.0
|
| 26 |
+
learning_rate: float = 2e-4
|
| 27 |
+
min_participants: int = 2
|
| 28 |
+
max_participants: int = 20
|
| 29 |
+
round_timeout_seconds: int = 3600
|
| 30 |
+
created_at: float = field(default_factory=time.time)
|
| 31 |
+
coordinator_sig: bytes = b""
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass
|
| 35 |
+
class ParticipantSubmission:
|
| 36 |
+
round_id: RoundID
|
| 37 |
+
participant_node_id: str
|
| 38 |
+
delta_bytes: bytes # serialised LoRA state dict subset (safetensors format)
|
| 39 |
+
num_samples: int
|
| 40 |
+
submitted_at: float = field(default_factory=time.time)
|
| 41 |
+
participant_sig: bytes = b""
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class FedLearnCoordinator:
|
| 45 |
+
"""Orchestrates a federated learning round.
|
| 46 |
+
|
| 47 |
+
Experimental. Requires peft and torch.
|
| 48 |
+
Only active when config.research.federated_learning = True.
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
def __init__(self, keypair=None, bus=None) -> None:
|
| 52 |
+
self._keypair = keypair
|
| 53 |
+
self._bus = bus
|
| 54 |
+
self._rounds: dict[RoundID, RoundManifest] = {}
|
| 55 |
+
self._submissions: dict[RoundID, list[ParticipantSubmission]] = {}
|
| 56 |
+
|
| 57 |
+
def create_round(
|
| 58 |
+
self,
|
| 59 |
+
base_model_id: str,
|
| 60 |
+
community_id: str,
|
| 61 |
+
**kwargs,
|
| 62 |
+
) -> RoundManifest:
|
| 63 |
+
"""Create a new federated learning round manifest."""
|
| 64 |
+
round_id = RoundID(str(uuid.uuid4()))
|
| 65 |
+
manifest = RoundManifest(
|
| 66 |
+
round_id=round_id,
|
| 67 |
+
base_model_id=base_model_id,
|
| 68 |
+
coordinator_node_id=getattr(self._keypair, "node_id_short", "unknown"),
|
| 69 |
+
community_id=community_id,
|
| 70 |
+
**kwargs,
|
| 71 |
+
)
|
| 72 |
+
self._rounds[round_id] = manifest
|
| 73 |
+
self._submissions[round_id] = []
|
| 74 |
+
return manifest
|
| 75 |
+
|
| 76 |
+
def submit(self, submission: ParticipantSubmission) -> bool:
|
| 77 |
+
"""Accept a participant's LoRA delta submission."""
|
| 78 |
+
if submission.round_id not in self._rounds:
|
| 79 |
+
return False
|
| 80 |
+
self._submissions[submission.round_id].append(submission)
|
| 81 |
+
return True
|
| 82 |
+
|
| 83 |
+
def aggregate(self, round_id: RoundID) -> bytes | None:
|
| 84 |
+
"""FedAvg: weighted average of submitted LoRA deltas.
|
| 85 |
+
|
| 86 |
+
Returns aggregated delta bytes or None if not enough participants.
|
| 87 |
+
Raises NotImplementedError — actual aggregation requires peft+torch.
|
| 88 |
+
"""
|
| 89 |
+
subs = self._submissions.get(round_id, [])
|
| 90 |
+
manifest = self._rounds.get(round_id)
|
| 91 |
+
if manifest is None or len(subs) < manifest.min_participants:
|
| 92 |
+
return None
|
| 93 |
+
raise NotImplementedError(
|
| 94 |
+
"FedLearnCoordinator.aggregate() requires peft and torch. "
|
| 95 |
+
"This is an experimental Phase 3 feature (M28)."
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
def round_status(self, round_id: RoundID) -> dict:
|
| 99 |
+
manifest = self._rounds.get(round_id)
|
| 100 |
+
if manifest is None:
|
| 101 |
+
return {"error": "not_found"}
|
| 102 |
+
subs = self._submissions.get(round_id, [])
|
| 103 |
+
return {
|
| 104 |
+
"round_id": round_id,
|
| 105 |
+
"base_model_id": manifest.base_model_id,
|
| 106 |
+
"participants": len(subs),
|
| 107 |
+
"min_required": manifest.min_participants,
|
| 108 |
+
"ready_to_aggregate": len(subs) >= manifest.min_participants,
|
| 109 |
+
}
|
|
@@ -1,42 +1,238 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
"""tokens.py — Phase 2 stub for capability tokens.
|
| 4 |
|
| 5 |
-
|
| 6 |
"""
|
|
|
|
| 7 |
|
|
|
|
|
|
|
|
|
|
| 8 |
from dataclasses import dataclass
|
| 9 |
from typing import Any
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
@dataclass(frozen=True)
|
| 13 |
class CapabilityToken:
|
| 14 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
def issue_token(
|
| 26 |
issuer_kp: Any,
|
| 27 |
subject_node_id: str,
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
def verify_token(token: CapabilityToken) -> bool:
|
| 36 |
-
"""Verify a capability token's signature and expiry. Phase 2 — not yet implemented."""
|
| 37 |
-
raise NotImplementedError("CapabilityToken verification is a Phase 2 feature.")
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
|
| 41 |
-
"""
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""hearthnet/identity/tokens.py — Capability tokens (M16, Phase 2).
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
Token format: hntoken://v1/<b64url(header)>.<b64url(payload)>.<b64url(sig)>
|
| 4 |
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
|
| 7 |
+
import base64
|
| 8 |
+
import json
|
| 9 |
+
import time
|
| 10 |
from dataclasses import dataclass
|
| 11 |
from typing import Any
|
| 12 |
|
| 13 |
+
try:
|
| 14 |
+
import nacl.public # noqa: F401 — presence check only
|
| 15 |
+
|
| 16 |
+
_NACL_AVAILABLE = True
|
| 17 |
+
except ImportError: # pragma: no cover
|
| 18 |
+
_NACL_AVAILABLE = False
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class TokenError(Exception):
|
| 22 |
+
"""Raised for all token-layer failures."""
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# Data types
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass(frozen=True)
|
| 31 |
+
class TokenScope:
|
| 32 |
+
"""Scope granted by a capability token."""
|
| 33 |
+
|
| 34 |
+
capabilities: list[str]
|
| 35 |
+
max_uses: int | None = None
|
| 36 |
+
max_calls_total: int | None = None
|
| 37 |
+
|
| 38 |
|
| 39 |
@dataclass(frozen=True)
|
| 40 |
class CapabilityToken:
|
| 41 |
+
"""A signed Ed25519 capability token."""
|
| 42 |
+
|
| 43 |
+
iss: str # issuer node_id (full form "ed25519:…")
|
| 44 |
+
sub: str # subject node_id or "*" for bearer token
|
| 45 |
+
aud: str # audience community_id or ""
|
| 46 |
+
iat: int # issued-at unix seconds
|
| 47 |
+
exp: int # expires-at unix seconds
|
| 48 |
+
nbf: int # not-before unix seconds
|
| 49 |
+
scope: TokenScope
|
| 50 |
+
jti: str # unique token ID (ULID)
|
| 51 |
+
issued_via: str # "federation"|"onboarding"|"manual"|"relay"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ---------------------------------------------------------------------------
|
| 55 |
+
# Encoding helpers
|
| 56 |
+
# ---------------------------------------------------------------------------
|
| 57 |
+
|
| 58 |
+
_TOKEN_SCHEME = "hntoken://v1/"
|
| 59 |
+
|
| 60 |
+
_HEADER = json.dumps({"alg": "EdDSA", "typ": "hntoken", "v": 1}, separators=(",", ":"))
|
| 61 |
+
_HEADER_B64 = base64.urlsafe_b64encode(_HEADER.encode()).rstrip(b"=").decode("ascii")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _b64url_encode(b: bytes) -> str:
|
| 65 |
+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _b64url_decode(s: str) -> bytes:
|
| 69 |
+
pad = 4 - len(s) % 4
|
| 70 |
+
if pad != 4:
|
| 71 |
+
s += "=" * pad
|
| 72 |
+
return base64.urlsafe_b64decode(s)
|
| 73 |
|
| 74 |
+
|
| 75 |
+
def _scope_to_dict(scope: TokenScope) -> dict[str, Any]:
|
| 76 |
+
return {
|
| 77 |
+
"capabilities": list(scope.capabilities),
|
| 78 |
+
"max_uses": scope.max_uses,
|
| 79 |
+
"max_calls_total": scope.max_calls_total,
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _scope_from_dict(d: dict[str, Any]) -> TokenScope:
|
| 84 |
+
return TokenScope(
|
| 85 |
+
capabilities=list(d.get("capabilities", [])),
|
| 86 |
+
max_uses=d.get("max_uses"),
|
| 87 |
+
max_calls_total=d.get("max_calls_total"),
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _payload_to_dict(tok: CapabilityToken) -> dict[str, Any]:
|
| 92 |
+
return {
|
| 93 |
+
"iss": tok.iss,
|
| 94 |
+
"sub": tok.sub,
|
| 95 |
+
"aud": tok.aud,
|
| 96 |
+
"iat": tok.iat,
|
| 97 |
+
"exp": tok.exp,
|
| 98 |
+
"nbf": tok.nbf,
|
| 99 |
+
"scope": _scope_to_dict(tok.scope),
|
| 100 |
+
"jti": tok.jti,
|
| 101 |
+
"issued_via": tok.issued_via,
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ---------------------------------------------------------------------------
|
| 106 |
+
# Issue
|
| 107 |
+
# ---------------------------------------------------------------------------
|
| 108 |
|
| 109 |
|
| 110 |
def issue_token(
|
| 111 |
issuer_kp: Any,
|
| 112 |
subject_node_id: str,
|
| 113 |
+
audience: str,
|
| 114 |
+
scope: TokenScope,
|
| 115 |
+
ttl_seconds: int = 3600,
|
| 116 |
+
issued_via: str = "manual",
|
| 117 |
+
) -> tuple[CapabilityToken, str]:
|
| 118 |
+
"""Issue a signed capability token.
|
| 119 |
+
|
| 120 |
+
Returns (CapabilityToken, encoded_token_string).
|
| 121 |
+
"""
|
| 122 |
+
if not _NACL_AVAILABLE:
|
| 123 |
+
raise TokenError("PyNaCl is required for token issuance. Install pynacl.")
|
| 124 |
+
|
| 125 |
+
from hearthnet.events.types import new_ulid
|
| 126 |
+
from hearthnet.identity.keys import full_node_id
|
| 127 |
+
|
| 128 |
+
now = int(time.time())
|
| 129 |
+
jti = new_ulid()
|
| 130 |
+
iss = full_node_id(bytes(issuer_kp.verify_key))
|
| 131 |
+
|
| 132 |
+
tok = CapabilityToken(
|
| 133 |
+
iss=iss,
|
| 134 |
+
sub=subject_node_id,
|
| 135 |
+
aud=audience,
|
| 136 |
+
iat=now,
|
| 137 |
+
exp=now + ttl_seconds,
|
| 138 |
+
nbf=now,
|
| 139 |
+
scope=scope,
|
| 140 |
+
jti=jti,
|
| 141 |
+
issued_via=issued_via,
|
| 142 |
+
)
|
| 143 |
+
payload_bytes = json.dumps(_payload_to_dict(tok), separators=(",", ":")).encode()
|
| 144 |
+
payload_b64 = _b64url_encode(payload_bytes)
|
| 145 |
+
signing_input = f"{_HEADER_B64}.{payload_b64}".encode()
|
| 146 |
+
|
| 147 |
+
try:
|
| 148 |
+
signed = issuer_kp.signing_key.sign(signing_input)
|
| 149 |
+
sig_b64 = _b64url_encode(signed.signature)
|
| 150 |
+
except Exception as exc:
|
| 151 |
+
raise TokenError(f"Signing failed: {exc}") from exc
|
| 152 |
+
|
| 153 |
+
encoded = encode_token(tok, sig_b64)
|
| 154 |
+
return tok, encoded
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def encode_token(tok: CapabilityToken, signature_b64: str) -> str:
|
| 158 |
+
"""Encode a CapabilityToken + pre-computed signature to the hntoken:// string."""
|
| 159 |
+
payload_bytes = json.dumps(_payload_to_dict(tok), separators=(",", ":")).encode()
|
| 160 |
+
payload_b64 = _b64url_encode(payload_bytes)
|
| 161 |
+
return f"{_TOKEN_SCHEME}{_HEADER_B64}.{payload_b64}.{signature_b64}"
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# ---------------------------------------------------------------------------
|
| 165 |
+
# Decode (structural only, no sig verify)
|
| 166 |
+
# ---------------------------------------------------------------------------
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def decode_token(text: str) -> CapabilityToken:
|
| 170 |
+
"""Parse an hntoken:// string. Validates structure; does NOT verify the signature."""
|
| 171 |
+
if not text.startswith(_TOKEN_SCHEME):
|
| 172 |
+
raise TokenError(f"Not a HearthNet token (expected 'hntoken://v1/'): {text[:40]!r}")
|
| 173 |
+
body = text[len(_TOKEN_SCHEME):]
|
| 174 |
+
parts = body.split(".")
|
| 175 |
+
if len(parts) != 3:
|
| 176 |
+
raise TokenError("Token must have exactly 3 dot-separated parts")
|
| 177 |
+
_header_b64, payload_b64, _sig_b64 = parts
|
| 178 |
+
try:
|
| 179 |
+
payload_bytes = _b64url_decode(payload_b64)
|
| 180 |
+
pd = json.loads(payload_bytes)
|
| 181 |
+
except Exception as exc:
|
| 182 |
+
raise TokenError(f"Failed to decode token payload: {exc}") from exc
|
| 183 |
+
|
| 184 |
+
required = {"iss", "sub", "aud", "iat", "exp", "nbf", "scope", "jti", "issued_via"}
|
| 185 |
+
missing = required - pd.keys()
|
| 186 |
+
if missing:
|
| 187 |
+
raise TokenError(f"Token payload missing fields: {missing}")
|
| 188 |
+
|
| 189 |
+
try:
|
| 190 |
+
scope = _scope_from_dict(pd["scope"])
|
| 191 |
+
return CapabilityToken(
|
| 192 |
+
iss=pd["iss"],
|
| 193 |
+
sub=pd["sub"],
|
| 194 |
+
aud=pd["aud"],
|
| 195 |
+
iat=int(pd["iat"]),
|
| 196 |
+
exp=int(pd["exp"]),
|
| 197 |
+
nbf=int(pd["nbf"]),
|
| 198 |
+
scope=scope,
|
| 199 |
+
jti=pd["jti"],
|
| 200 |
+
issued_via=pd["issued_via"],
|
| 201 |
+
)
|
| 202 |
+
except (KeyError, TypeError, ValueError) as exc:
|
| 203 |
+
raise TokenError(f"Malformed token payload: {exc}") from exc
|
| 204 |
+
|
| 205 |
|
| 206 |
+
# ---------------------------------------------------------------------------
|
| 207 |
+
# Verify
|
| 208 |
+
# ---------------------------------------------------------------------------
|
| 209 |
|
|
|
|
|
|
|
|
|
|
| 210 |
|
| 211 |
+
def verify_token(
|
| 212 |
+
tok: CapabilityToken,
|
| 213 |
+
community_manifest: Any | None = None,
|
| 214 |
+
now: int | None = None,
|
| 215 |
+
) -> None:
|
| 216 |
+
"""Verify token validity (expiry, not-before). Raises TokenError on failure.
|
| 217 |
|
| 218 |
+
If community_manifest is provided, the issuer is checked against membership.
|
| 219 |
+
"""
|
| 220 |
+
ts = now if now is not None else int(time.time())
|
| 221 |
+
if ts < tok.nbf:
|
| 222 |
+
raise TokenError(f"Token not yet valid (nbf={tok.nbf}, now={ts})")
|
| 223 |
+
if ts >= tok.exp:
|
| 224 |
+
raise TokenError(f"Token expired (exp={tok.exp}, now={ts})")
|
| 225 |
+
if not tok.iss.startswith("ed25519:"):
|
| 226 |
+
raise TokenError(f"Issuer must be a full node_id: {tok.iss!r}")
|
| 227 |
+
if not tok.jti:
|
| 228 |
+
raise TokenError("Token has no jti")
|
| 229 |
+
if community_manifest is not None:
|
| 230 |
+
members = getattr(community_manifest, "members", None) or {}
|
| 231 |
+
if isinstance(members, dict):
|
| 232 |
+
member_ids = set(members.keys())
|
| 233 |
+
elif isinstance(members, list):
|
| 234 |
+
member_ids = set(members)
|
| 235 |
+
else:
|
| 236 |
+
member_ids = set()
|
| 237 |
+
if member_ids and tok.iss not in member_ids:
|
| 238 |
+
raise TokenError(f"Token issuer {tok.iss!r} is not a member of the community")
|
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LoRa hardware beacons package (experimental, Phase 3 — M29)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from hearthnet.lora.service import LoraBeacon, LoraBeaconService, decode_beacon_frame, encode_beacon_frame
|
| 5 |
+
|
| 6 |
+
__all__ = ["LoraBeacon", "LoraBeaconService", "decode_beacon_frame", "encode_beacon_frame"]
|
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""M29 — LoRa Hardware Beacons (experimental, Phase 3).
|
| 2 |
+
|
| 3 |
+
868 MHz LoRa "I'm still here" beacons for offline emergency presence.
|
| 4 |
+
No AI traffic, no chat, no file transfer — only 32-byte heartbeat frames.
|
| 5 |
+
Gated by config.research.lora_beacons = True.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import struct
|
| 10 |
+
import time
|
| 11 |
+
from dataclasses import dataclass, field
|
| 12 |
+
from typing import NewType
|
| 13 |
+
|
| 14 |
+
LoraBeaconID = NewType("LoraBeaconID", str)
|
| 15 |
+
LoraDeviceID = NewType("LoraDeviceID", str)
|
| 16 |
+
|
| 17 |
+
# Frame layout: 32 bytes
|
| 18 |
+
# [0:4] magic (b"HN01")
|
| 19 |
+
# [4:8] sequence (uint32 big-endian)
|
| 20 |
+
# [8:16] node_id_hash (first 8 bytes of SHA-256 of node_id_full)
|
| 21 |
+
# [16:17] flags (bit0=emergency, bit1=panic)
|
| 22 |
+
# [17:32] reserved (zeros)
|
| 23 |
+
FRAME_MAGIC = b"HN01"
|
| 24 |
+
FRAME_SIZE = 32
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass(frozen=True)
|
| 28 |
+
class LoraBeacon:
|
| 29 |
+
beacon_id: LoraBeaconID
|
| 30 |
+
device_id: LoraDeviceID
|
| 31 |
+
node_id_hash: bytes # 8 bytes
|
| 32 |
+
sequence: int
|
| 33 |
+
flags: int # bit0=emergency, bit1=panic
|
| 34 |
+
rssi: int | None = None # dBm, if available
|
| 35 |
+
received_at: float = field(default_factory=time.time)
|
| 36 |
+
|
| 37 |
+
@property
|
| 38 |
+
def is_emergency(self) -> bool:
|
| 39 |
+
return bool(self.flags & 0x01)
|
| 40 |
+
|
| 41 |
+
@property
|
| 42 |
+
def is_panic(self) -> bool:
|
| 43 |
+
return bool(self.flags & 0x02)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def encode_beacon_frame(node_id_full: str, sequence: int, flags: int = 0) -> bytes:
|
| 47 |
+
"""Encode a 32-byte LoRa beacon frame."""
|
| 48 |
+
import hashlib
|
| 49 |
+
node_hash = hashlib.sha256(node_id_full.encode()).digest()[:8]
|
| 50 |
+
header = struct.pack(">4sI8sB", FRAME_MAGIC, sequence, node_hash, flags)
|
| 51 |
+
return header + b"\x00" * (FRAME_SIZE - len(header))
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def decode_beacon_frame(raw: bytes, device_id: str = "unknown") -> LoraBeacon | None:
|
| 55 |
+
"""Decode a 32-byte LoRa frame. Returns None if invalid."""
|
| 56 |
+
if len(raw) < FRAME_SIZE:
|
| 57 |
+
return None
|
| 58 |
+
magic, sequence, node_hash, flags = struct.unpack_from(">4sI8sB", raw)
|
| 59 |
+
if magic != FRAME_MAGIC:
|
| 60 |
+
return None
|
| 61 |
+
return LoraBeacon(
|
| 62 |
+
beacon_id=LoraBeaconID(f"{device_id}:{sequence}"),
|
| 63 |
+
device_id=LoraDeviceID(device_id),
|
| 64 |
+
node_id_hash=node_hash,
|
| 65 |
+
sequence=sequence,
|
| 66 |
+
flags=flags,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class LoraBeaconService:
|
| 71 |
+
"""Sends and receives LoRa beacons.
|
| 72 |
+
|
| 73 |
+
Requires a USB LoRa stick (RFM95W, sx1276, sx1262 via serial bridge).
|
| 74 |
+
Falls back to simulation mode if no hardware detected.
|
| 75 |
+
Only active when config.research.lora_beacons = True.
|
| 76 |
+
"""
|
| 77 |
+
|
| 78 |
+
def __init__(self, serial_port: str | None = None, node_id_full: str = "") -> None:
|
| 79 |
+
self._serial_port = serial_port
|
| 80 |
+
self._node_id_full = node_id_full
|
| 81 |
+
self._sequence = 0
|
| 82 |
+
self._received: list[LoraBeacon] = []
|
| 83 |
+
self._simulated = serial_port is None
|
| 84 |
+
|
| 85 |
+
def send_heartbeat(self, flags: int = 0) -> bytes:
|
| 86 |
+
"""Encode and (if hardware present) transmit a heartbeat frame."""
|
| 87 |
+
frame = encode_beacon_frame(self._node_id_full, self._sequence, flags)
|
| 88 |
+
self._sequence += 1
|
| 89 |
+
if not self._simulated:
|
| 90 |
+
self._transmit(frame)
|
| 91 |
+
return frame
|
| 92 |
+
|
| 93 |
+
def _transmit(self, frame: bytes) -> None:
|
| 94 |
+
"""Write frame to serial LoRa hardware (stub — real impl needs pyserial)."""
|
| 95 |
+
try:
|
| 96 |
+
import serial # type: ignore[import-untyped]
|
| 97 |
+
with serial.Serial(self._serial_port, baudrate=9600, timeout=1) as ser:
|
| 98 |
+
ser.write(frame)
|
| 99 |
+
except ImportError:
|
| 100 |
+
pass # pyserial not installed — silently skip
|
| 101 |
+
|
| 102 |
+
def receive_frame(self, raw: bytes, device_id: str = "unknown") -> LoraBeacon | None:
|
| 103 |
+
"""Decode an incoming frame and record it."""
|
| 104 |
+
beacon = decode_beacon_frame(raw, device_id)
|
| 105 |
+
if beacon is not None:
|
| 106 |
+
self._received.append(beacon)
|
| 107 |
+
return beacon
|
| 108 |
+
|
| 109 |
+
def recent_beacons(self, window_seconds: float = 300.0) -> list[LoraBeacon]:
|
| 110 |
+
cutoff = time.time() - window_seconds
|
| 111 |
+
return [b for b in self._received if b.received_at >= cutoff]
|
| 112 |
+
|
| 113 |
+
def health(self) -> dict:
|
| 114 |
+
return {
|
| 115 |
+
"hardware": "detected" if not self._simulated else "simulated",
|
| 116 |
+
"serial_port": self._serial_port,
|
| 117 |
+
"sent": self._sequence,
|
| 118 |
+
"received": len(self._received),
|
| 119 |
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""M22 — Mobile Native — Python anchor-side helpers.
|
| 2 |
+
|
| 3 |
+
The Flutter mobile app lives in a separate repo (``mobile-native/``).
|
| 4 |
+
This package provides the Python-side services that a community anchor
|
| 5 |
+
exposes specifically for mobile clients:
|
| 6 |
+
|
| 7 |
+
- ``invite.py`` — mobile-targeted invite QR / deep-link generation
|
| 8 |
+
- ``push_authority.py`` — push token registry + notification dispatch
|
| 9 |
+
"""
|
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""M22 — Mobile invite helpers.
|
| 2 |
+
|
| 3 |
+
Generates mobile-targeted invite deep links (``hnapp://``) and QR codes
|
| 4 |
+
for the mobile native client (Flutter). Builds on top of the Phase 1
|
| 5 |
+
onboarding module (M13).
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import base64
|
| 10 |
+
import hashlib
|
| 11 |
+
import json
|
| 12 |
+
import time
|
| 13 |
+
from dataclasses import dataclass, field
|
| 14 |
+
from typing import Any
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# ---------------------------------------------------------------------------
|
| 18 |
+
# Invite blob for mobile clients
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass(frozen=True)
|
| 23 |
+
class MobileInviteBlob:
|
| 24 |
+
"""Compact invite that the mobile app can parse from a QR or deep link.
|
| 25 |
+
|
| 26 |
+
Wire format: ``hnapp://v1/<b64url(json_payload)>``
|
| 27 |
+
The payload is JSON with fields defined below.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
community_id: str
|
| 31 |
+
community_name: str
|
| 32 |
+
anchor_endpoints: list[str]
|
| 33 |
+
"""HTTP(S) or WebSocket URLs of community anchors the app can reach."""
|
| 34 |
+
|
| 35 |
+
invited_by: str
|
| 36 |
+
"""node_id of the inviting user (display hint only)."""
|
| 37 |
+
|
| 38 |
+
relay_url: str | None = None
|
| 39 |
+
"""Optional relay tier URL for NAT-traversal push delivery (M15)."""
|
| 40 |
+
|
| 41 |
+
invite_token: str | None = None
|
| 42 |
+
"""One-time capability token the app exchanges on first contact (M16)."""
|
| 43 |
+
|
| 44 |
+
created_at: float = field(default_factory=time.time)
|
| 45 |
+
expires_at: float | None = None
|
| 46 |
+
|
| 47 |
+
def is_expired(self, now: float | None = None) -> bool:
|
| 48 |
+
t = now if now is not None else time.time()
|
| 49 |
+
return self.expires_at is not None and t > self.expires_at
|
| 50 |
+
|
| 51 |
+
# ------------------------------------------------------------------
|
| 52 |
+
# Serialization
|
| 53 |
+
# ------------------------------------------------------------------
|
| 54 |
+
|
| 55 |
+
def to_dict(self) -> dict:
|
| 56 |
+
return {
|
| 57 |
+
"v": 1,
|
| 58 |
+
"community_id": self.community_id,
|
| 59 |
+
"community_name": self.community_name,
|
| 60 |
+
"anchor_endpoints": self.anchor_endpoints,
|
| 61 |
+
"invited_by": self.invited_by,
|
| 62 |
+
"relay_url": self.relay_url,
|
| 63 |
+
"invite_token": self.invite_token,
|
| 64 |
+
"created_at": self.created_at,
|
| 65 |
+
"expires_at": self.expires_at,
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
def to_deep_link(self) -> str:
|
| 69 |
+
"""Encode as ``hnapp://v1/<b64url>``."""
|
| 70 |
+
payload = json.dumps(self.to_dict(), separators=(",", ":"), sort_keys=True)
|
| 71 |
+
b64 = base64.urlsafe_b64encode(payload.encode()).rstrip(b"=").decode()
|
| 72 |
+
return f"hnapp://v1/{b64}"
|
| 73 |
+
|
| 74 |
+
def fingerprint(self) -> str:
|
| 75 |
+
"""SHA-256 (hex, 16 chars) of the JSON payload for logging."""
|
| 76 |
+
raw = json.dumps(self.to_dict(), separators=(",", ":"), sort_keys=True)
|
| 77 |
+
return hashlib.sha256(raw.encode()).hexdigest()[:16]
|
| 78 |
+
|
| 79 |
+
@classmethod
|
| 80 |
+
def from_deep_link(cls, deep_link: str) -> "MobileInviteBlob":
|
| 81 |
+
"""Parse a deep link produced by :meth:`to_deep_link`."""
|
| 82 |
+
if not deep_link.startswith("hnapp://v1/"):
|
| 83 |
+
raise ValueError(f"Not a valid hnapp:// deep link: {deep_link!r}")
|
| 84 |
+
b64 = deep_link[len("hnapp://v1/"):]
|
| 85 |
+
# Re-add padding
|
| 86 |
+
padding = 4 - len(b64) % 4
|
| 87 |
+
if padding != 4:
|
| 88 |
+
b64 += "=" * padding
|
| 89 |
+
payload = json.loads(base64.urlsafe_b64decode(b64).decode())
|
| 90 |
+
return cls(
|
| 91 |
+
community_id=payload["community_id"],
|
| 92 |
+
community_name=payload["community_name"],
|
| 93 |
+
anchor_endpoints=payload["anchor_endpoints"],
|
| 94 |
+
invited_by=payload["invited_by"],
|
| 95 |
+
relay_url=payload.get("relay_url"),
|
| 96 |
+
invite_token=payload.get("invite_token"),
|
| 97 |
+
created_at=payload.get("created_at", time.time()),
|
| 98 |
+
expires_at=payload.get("expires_at"),
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# ---------------------------------------------------------------------------
|
| 103 |
+
# QR code rendering (qrcode optional)
|
| 104 |
+
# ---------------------------------------------------------------------------
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def render_qr_svg(blob: MobileInviteBlob) -> str | None:
|
| 108 |
+
"""Return an SVG string for the invite QR code, or None if ``qrcode`` is
|
| 109 |
+
not installed. The SVG can be embedded directly in HTML."""
|
| 110 |
+
try:
|
| 111 |
+
import qrcode # type: ignore
|
| 112 |
+
import qrcode.image.svg # type: ignore
|
| 113 |
+
|
| 114 |
+
factory = qrcode.image.svg.SvgPathImage
|
| 115 |
+
qr = qrcode.make(blob.to_deep_link(), image_factory=factory)
|
| 116 |
+
import io
|
| 117 |
+
|
| 118 |
+
buf = io.BytesIO()
|
| 119 |
+
qr.save(buf)
|
| 120 |
+
return buf.getvalue().decode("utf-8")
|
| 121 |
+
except ImportError:
|
| 122 |
+
return None
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def render_qr_terminal(blob: MobileInviteBlob) -> str:
|
| 126 |
+
"""Return the QR code as ASCII art (uses ``qrcode`` if available, else
|
| 127 |
+
falls back to the raw deep link)."""
|
| 128 |
+
try:
|
| 129 |
+
import qrcode # type: ignore
|
| 130 |
+
|
| 131 |
+
qr = qrcode.QRCode()
|
| 132 |
+
qr.add_data(blob.to_deep_link())
|
| 133 |
+
qr.make(fit=True)
|
| 134 |
+
import io
|
| 135 |
+
|
| 136 |
+
buf = io.StringIO()
|
| 137 |
+
qr.print_ascii(out=buf)
|
| 138 |
+
return buf.getvalue()
|
| 139 |
+
except ImportError:
|
| 140 |
+
return blob.to_deep_link()
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ---------------------------------------------------------------------------
|
| 144 |
+
# Factory helper
|
| 145 |
+
# ---------------------------------------------------------------------------
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def build_mobile_invite(
|
| 149 |
+
community_id: str,
|
| 150 |
+
community_name: str,
|
| 151 |
+
anchor_endpoints: list[str],
|
| 152 |
+
invited_by: str,
|
| 153 |
+
relay_url: str | None = None,
|
| 154 |
+
invite_token: str | None = None,
|
| 155 |
+
ttl_seconds: float = 86_400 * 7, # 7 days default
|
| 156 |
+
) -> MobileInviteBlob:
|
| 157 |
+
"""Create a :class:`MobileInviteBlob` with a default 7-day TTL."""
|
| 158 |
+
now = time.time()
|
| 159 |
+
return MobileInviteBlob(
|
| 160 |
+
community_id=community_id,
|
| 161 |
+
community_name=community_name,
|
| 162 |
+
anchor_endpoints=anchor_endpoints,
|
| 163 |
+
invited_by=invited_by,
|
| 164 |
+
relay_url=relay_url,
|
| 165 |
+
invite_token=invite_token,
|
| 166 |
+
created_at=now,
|
| 167 |
+
expires_at=now + ttl_seconds,
|
| 168 |
+
)
|
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""M22 — Mobile push token authority.
|
| 2 |
+
|
| 3 |
+
The anchor-side service that mobile clients (Flutter) call to:
|
| 4 |
+
1. Register their APNs/FCM push token → ``mobile.push.register@1.0``
|
| 5 |
+
2. Deregister (logout / token rotation) → ``mobile.push.deregister@1.0``
|
| 6 |
+
3. Receive in-app notifications sent by other services
|
| 7 |
+
|
| 8 |
+
Push delivery itself is *out of scope* for the local-first anchor — if the
|
| 9 |
+
relay tier (M15) is configured the anchor forwards the notification there.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import hashlib
|
| 14 |
+
import time
|
| 15 |
+
from dataclasses import dataclass, field
|
| 16 |
+
from typing import Any, TYPE_CHECKING
|
| 17 |
+
|
| 18 |
+
if TYPE_CHECKING: # pragma: no cover
|
| 19 |
+
from hearthnet.bus.router import Router
|
| 20 |
+
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
# Data types
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class PushToken:
|
| 28 |
+
"""A mobile push token registration entry."""
|
| 29 |
+
|
| 30 |
+
node_id: str
|
| 31 |
+
"""The HearthNet node ID of the mobile device."""
|
| 32 |
+
|
| 33 |
+
token: str
|
| 34 |
+
"""Raw APNs device token or FCM registration ID."""
|
| 35 |
+
|
| 36 |
+
platform: str
|
| 37 |
+
"""``apns`` | ``fcm`` | ``simulator``."""
|
| 38 |
+
|
| 39 |
+
registered_at: float = field(default_factory=time.time)
|
| 40 |
+
last_seen_at: float = field(default_factory=time.time)
|
| 41 |
+
|
| 42 |
+
def touch(self) -> None:
|
| 43 |
+
self.last_seen_at = time.time()
|
| 44 |
+
|
| 45 |
+
@property
|
| 46 |
+
def token_hash(self) -> str:
|
| 47 |
+
"""SHA-256 of the raw token (for logging without leaking the token)."""
|
| 48 |
+
return hashlib.sha256(self.token.encode()).hexdigest()[:12]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@dataclass(frozen=True)
|
| 52 |
+
class PushNotification:
|
| 53 |
+
"""A notification to be delivered to one or more mobile devices."""
|
| 54 |
+
|
| 55 |
+
title: str
|
| 56 |
+
body: str
|
| 57 |
+
data: dict = field(default_factory=dict)
|
| 58 |
+
"""Arbitrary key/value payload forwarded to the app."""
|
| 59 |
+
|
| 60 |
+
badge: int | None = None
|
| 61 |
+
"""iOS badge count; ``None`` leaves the current badge unchanged."""
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ---------------------------------------------------------------------------
|
| 65 |
+
# In-memory token store
|
| 66 |
+
# ---------------------------------------------------------------------------
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class PushTokenRegistry:
|
| 70 |
+
"""Thread-safe in-memory registry of push tokens.
|
| 71 |
+
|
| 72 |
+
In production you would back this with the SQLite event store (M07 / X02)
|
| 73 |
+
or a dedicated table. For Phase 1 and 2 the in-memory approach is
|
| 74 |
+
sufficient for single-process deployments.
|
| 75 |
+
"""
|
| 76 |
+
|
| 77 |
+
def __init__(self) -> None:
|
| 78 |
+
self._tokens: dict[str, list[PushToken]] = {}
|
| 79 |
+
"""node_id → list of tokens (a user may have multiple devices)."""
|
| 80 |
+
|
| 81 |
+
def register(self, node_id: str, token: str, platform: str) -> PushToken:
|
| 82 |
+
entry = PushToken(node_id=node_id, token=token, platform=platform)
|
| 83 |
+
self._tokens.setdefault(node_id, [])
|
| 84 |
+
# Deduplicate by token value
|
| 85 |
+
existing = {t.token for t in self._tokens[node_id]}
|
| 86 |
+
if token not in existing:
|
| 87 |
+
self._tokens[node_id].append(entry)
|
| 88 |
+
else:
|
| 89 |
+
for t in self._tokens[node_id]:
|
| 90 |
+
if t.token == token:
|
| 91 |
+
t.touch()
|
| 92 |
+
entry = t
|
| 93 |
+
return entry
|
| 94 |
+
|
| 95 |
+
def deregister(self, node_id: str, token: str) -> bool:
|
| 96 |
+
before = self._tokens.get(node_id, [])
|
| 97 |
+
after = [t for t in before if t.token != token]
|
| 98 |
+
self._tokens[node_id] = after
|
| 99 |
+
return len(after) < len(before)
|
| 100 |
+
|
| 101 |
+
def get_tokens(self, node_id: str) -> list[PushToken]:
|
| 102 |
+
return list(self._tokens.get(node_id, []))
|
| 103 |
+
|
| 104 |
+
def all_node_ids(self) -> list[str]:
|
| 105 |
+
return list(self._tokens.keys())
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# ---------------------------------------------------------------------------
|
| 109 |
+
# Bus service
|
| 110 |
+
# ---------------------------------------------------------------------------
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
class MobilePushService:
|
| 114 |
+
"""Registers as a bus capability provider for mobile push operations.
|
| 115 |
+
|
| 116 |
+
Capabilities provided:
|
| 117 |
+
- ``mobile.push.register@1.0``
|
| 118 |
+
- ``mobile.push.deregister@1.0``
|
| 119 |
+
- ``mobile.push.notify@1.0`` (internal — other services call this)
|
| 120 |
+
"""
|
| 121 |
+
|
| 122 |
+
def __init__(
|
| 123 |
+
self,
|
| 124 |
+
relay_url: str | None = None,
|
| 125 |
+
bus: "Router | None" = None,
|
| 126 |
+
) -> None:
|
| 127 |
+
self._registry = PushTokenRegistry()
|
| 128 |
+
self._relay_url = relay_url
|
| 129 |
+
self._bus = bus
|
| 130 |
+
|
| 131 |
+
# ------------------------------------------------------------------
|
| 132 |
+
# Health
|
| 133 |
+
# ------------------------------------------------------------------
|
| 134 |
+
|
| 135 |
+
def health(self) -> dict:
|
| 136 |
+
total_tokens = sum(len(v) for v in self._registry._tokens.values())
|
| 137 |
+
return {
|
| 138 |
+
"status": "ok",
|
| 139 |
+
"registered_devices": total_tokens,
|
| 140 |
+
"relay_url": self._relay_url,
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
# ------------------------------------------------------------------
|
| 144 |
+
# Handlers called by the bus router
|
| 145 |
+
# ------------------------------------------------------------------
|
| 146 |
+
|
| 147 |
+
def handle_register(self, params: dict) -> dict:
|
| 148 |
+
"""``mobile.push.register@1.0`` handler.
|
| 149 |
+
|
| 150 |
+
Expected input::
|
| 151 |
+
|
| 152 |
+
{
|
| 153 |
+
"node_id": "<HearthNet node ID>",
|
| 154 |
+
"token": "<APNs device token or FCM registration ID>",
|
| 155 |
+
"platform": "apns" | "fcm" | "simulator"
|
| 156 |
+
}
|
| 157 |
+
"""
|
| 158 |
+
inp = params.get("input", params)
|
| 159 |
+
node_id = str(inp.get("node_id", ""))
|
| 160 |
+
token = str(inp.get("token", ""))
|
| 161 |
+
platform = str(inp.get("platform", "unknown"))
|
| 162 |
+
if not node_id or not token:
|
| 163 |
+
return {"output": {"error": "bad_request", "detail": "node_id and token required"}, "meta": {}}
|
| 164 |
+
entry = self._registry.register(node_id, token, platform)
|
| 165 |
+
return {"output": {"status": "registered", "token_hash": entry.token_hash}, "meta": {}}
|
| 166 |
+
|
| 167 |
+
def handle_deregister(self, params: dict) -> dict:
|
| 168 |
+
"""``mobile.push.deregister@1.0`` handler."""
|
| 169 |
+
inp = params.get("input", params)
|
| 170 |
+
node_id = str(inp.get("node_id", ""))
|
| 171 |
+
token = str(inp.get("token", ""))
|
| 172 |
+
removed = self._registry.deregister(node_id, token)
|
| 173 |
+
return {"output": {"status": "removed" if removed else "not_found"}, "meta": {}}
|
| 174 |
+
|
| 175 |
+
async def handle_notify(self, params: dict) -> dict:
|
| 176 |
+
"""``mobile.push.notify@1.0`` — internal notification dispatch.
|
| 177 |
+
|
| 178 |
+
Sends a :class:`PushNotification` to all registered devices of
|
| 179 |
+
``target_node_id``. If a relay URL is configured, forwards there;
|
| 180 |
+
otherwise logs and returns ``{"status": "no_relay"}``.
|
| 181 |
+
"""
|
| 182 |
+
inp = params.get("input", params)
|
| 183 |
+
target = str(inp.get("target_node_id", ""))
|
| 184 |
+
notif = PushNotification(
|
| 185 |
+
title=str(inp.get("title", "")),
|
| 186 |
+
body=str(inp.get("body", "")),
|
| 187 |
+
data=inp.get("data", {}),
|
| 188 |
+
badge=inp.get("badge"),
|
| 189 |
+
)
|
| 190 |
+
tokens = self._registry.get_tokens(target)
|
| 191 |
+
if not tokens:
|
| 192 |
+
return {"output": {"status": "no_tokens", "delivered": 0}, "meta": {}}
|
| 193 |
+
|
| 194 |
+
if self._relay_url:
|
| 195 |
+
delivered = await self._forward_to_relay(target, tokens, notif)
|
| 196 |
+
return {"output": {"status": "forwarded", "delivered": delivered}, "meta": {}}
|
| 197 |
+
|
| 198 |
+
# No relay configured — local delivery only (simulator / test)
|
| 199 |
+
return {"output": {"status": "no_relay", "delivered": 0}, "meta": {}}
|
| 200 |
+
|
| 201 |
+
# ------------------------------------------------------------------
|
| 202 |
+
# Internal
|
| 203 |
+
# ------------------------------------------------------------------
|
| 204 |
+
|
| 205 |
+
async def _forward_to_relay(
|
| 206 |
+
self,
|
| 207 |
+
target_node_id: str,
|
| 208 |
+
tokens: list[PushToken],
|
| 209 |
+
notif: PushNotification,
|
| 210 |
+
) -> int:
|
| 211 |
+
"""POST the notification to the relay tier (M15) for each token."""
|
| 212 |
+
try:
|
| 213 |
+
import httpx # type: ignore
|
| 214 |
+
except ImportError:
|
| 215 |
+
return 0
|
| 216 |
+
|
| 217 |
+
payload = {
|
| 218 |
+
"target_node_id": target_node_id,
|
| 219 |
+
"tokens": [{"token": t.token, "platform": t.platform} for t in tokens],
|
| 220 |
+
"notification": {
|
| 221 |
+
"title": notif.title,
|
| 222 |
+
"body": notif.body,
|
| 223 |
+
"data": notif.data,
|
| 224 |
+
"badge": notif.badge,
|
| 225 |
+
},
|
| 226 |
+
}
|
| 227 |
+
delivered = 0
|
| 228 |
+
async with httpx.AsyncClient(timeout=10) as client:
|
| 229 |
+
try:
|
| 230 |
+
resp = await client.post(
|
| 231 |
+
f"{self._relay_url}/push/v1/send",
|
| 232 |
+
json=payload,
|
| 233 |
+
)
|
| 234 |
+
if resp.status_code == 200:
|
| 235 |
+
delivered = len(tokens)
|
| 236 |
+
except httpx.HTTPError:
|
| 237 |
+
pass
|
| 238 |
+
return delivered
|
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""M27 — MoE Expert Routing package (experimental, Phase 3)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from hearthnet.moe.router import ExpertDescriptor, ExpertRegistry, MoeRouter, RouteResult
|
| 5 |
+
|
| 6 |
+
__all__ = ["ExpertDescriptor", "ExpertRegistry", "MoeRouter", "RouteResult"]
|
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""M27 — MoE Expert Routing (experimental, Phase 3).
|
| 2 |
+
|
| 3 |
+
Routes queries to the best expert: local model, service capability, human, or external.
|
| 4 |
+
Gated by config.research.moe_routing = True.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import time
|
| 9 |
+
import uuid
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
ExpertID = str
|
| 14 |
+
ThreadID = str
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass(frozen=True)
|
| 18 |
+
class ExpertDescriptor:
|
| 19 |
+
"""Describes an available expert (model, service, human, or external)."""
|
| 20 |
+
expert_id: ExpertID # "human:<NodeID>" | "model:<id>" | "service:<cap>" | "external:<url>"
|
| 21 |
+
expert_type: str # "human" | "model" | "service" | "external"
|
| 22 |
+
topic_tags: frozenset[str]
|
| 23 |
+
confidence_score: float # 0.0-1.0, self-reported
|
| 24 |
+
community_id: str
|
| 25 |
+
name: str | None = None
|
| 26 |
+
description: str | None = None
|
| 27 |
+
expires_at: float | None = None
|
| 28 |
+
|
| 29 |
+
def is_expired(self, now: float | None = None) -> bool:
|
| 30 |
+
if self.expires_at is None:
|
| 31 |
+
return False
|
| 32 |
+
return (now or time.time()) > self.expires_at
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class RouteCandidate:
|
| 37 |
+
expert_id: ExpertID
|
| 38 |
+
score: float
|
| 39 |
+
reason: str
|
| 40 |
+
expert_type: str
|
| 41 |
+
name: str | None = None
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@dataclass
|
| 45 |
+
class RouteResult:
|
| 46 |
+
candidates: list[RouteCandidate]
|
| 47 |
+
query_summary: str
|
| 48 |
+
routed_at: float = field(default_factory=time.time)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@dataclass
|
| 52 |
+
class Handoff:
|
| 53 |
+
"""A pending handoff to a human expert."""
|
| 54 |
+
handoff_id: str
|
| 55 |
+
expert_id: ExpertID
|
| 56 |
+
query: str
|
| 57 |
+
created_at: float = field(default_factory=time.time)
|
| 58 |
+
resolved_at: float | None = None
|
| 59 |
+
status: str = "pending" # "pending" | "accepted" | "declined" | "timeout"
|
| 60 |
+
thread_id: ThreadID | None = None
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class ExpertRegistry:
|
| 64 |
+
"""Tracks registered experts and their declared topics."""
|
| 65 |
+
|
| 66 |
+
def __init__(self) -> None:
|
| 67 |
+
self._experts: dict[ExpertID, ExpertDescriptor] = {}
|
| 68 |
+
|
| 69 |
+
def register(self, descriptor: ExpertDescriptor) -> None:
|
| 70 |
+
self._experts[descriptor.expert_id] = descriptor
|
| 71 |
+
|
| 72 |
+
def unregister(self, expert_id: ExpertID) -> bool:
|
| 73 |
+
if expert_id in self._experts:
|
| 74 |
+
del self._experts[expert_id]
|
| 75 |
+
return True
|
| 76 |
+
return False
|
| 77 |
+
|
| 78 |
+
def list_active(self, now: float | None = None) -> list[ExpertDescriptor]:
|
| 79 |
+
now = now or time.time()
|
| 80 |
+
return [e for e in self._experts.values() if not e.is_expired(now)]
|
| 81 |
+
|
| 82 |
+
def find_by_tags(self, tags: set[str], now: float | None = None) -> list[ExpertDescriptor]:
|
| 83 |
+
active = self.list_active(now)
|
| 84 |
+
return [e for e in active if e.topic_tags & tags]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class MoeRouter:
|
| 88 |
+
"""Recommends experts for a query.
|
| 89 |
+
|
| 90 |
+
Uses a simple rule-based scorer in Phase 3.
|
| 91 |
+
A learned scorer (embedding-based) is planned but not yet implemented.
|
| 92 |
+
Only active when config.research.moe_routing = True.
|
| 93 |
+
"""
|
| 94 |
+
|
| 95 |
+
def __init__(self, registry: ExpertRegistry | None = None, bus=None) -> None:
|
| 96 |
+
self._registry = registry or ExpertRegistry()
|
| 97 |
+
self._bus = bus
|
| 98 |
+
self._pending_handoffs: dict[str, Handoff] = {}
|
| 99 |
+
|
| 100 |
+
@property
|
| 101 |
+
def registry(self) -> ExpertRegistry:
|
| 102 |
+
return self._registry
|
| 103 |
+
|
| 104 |
+
def route(self, query: str, top_k: int = 3, tags: set[str] | None = None) -> RouteResult:
|
| 105 |
+
"""Return top-K expert candidates for a query."""
|
| 106 |
+
candidates_src = self._registry.list_active()
|
| 107 |
+
if tags:
|
| 108 |
+
candidates_src = [e for e in candidates_src if e.topic_tags & tags]
|
| 109 |
+
|
| 110 |
+
# Simple scoring: exact tag matches + confidence weight
|
| 111 |
+
query_words = set(query.lower().split())
|
| 112 |
+
scored: list[RouteCandidate] = []
|
| 113 |
+
for expert in candidates_src:
|
| 114 |
+
tag_overlap = len(expert.topic_tags & query_words)
|
| 115 |
+
score = expert.confidence_score * (1.0 + 0.2 * tag_overlap)
|
| 116 |
+
scored.append(RouteCandidate(
|
| 117 |
+
expert_id=expert.expert_id,
|
| 118 |
+
score=min(score, 1.0),
|
| 119 |
+
reason=f"tag_overlap={tag_overlap}, confidence={expert.confidence_score:.2f}",
|
| 120 |
+
expert_type=expert.expert_type,
|
| 121 |
+
name=expert.name,
|
| 122 |
+
))
|
| 123 |
+
|
| 124 |
+
scored.sort(key=lambda c: c.score, reverse=True)
|
| 125 |
+
return RouteResult(
|
| 126 |
+
candidates=scored[:top_k],
|
| 127 |
+
query_summary=query[:200],
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
def initiate_handoff(self, expert_id: ExpertID, query: str, thread_id: str | None = None) -> Handoff:
|
| 131 |
+
"""Create a pending handoff to a human expert."""
|
| 132 |
+
h = Handoff(
|
| 133 |
+
handoff_id=str(uuid.uuid4()),
|
| 134 |
+
expert_id=expert_id,
|
| 135 |
+
query=query,
|
| 136 |
+
thread_id=thread_id,
|
| 137 |
+
)
|
| 138 |
+
self._pending_handoffs[h.handoff_id] = h
|
| 139 |
+
return h
|
| 140 |
+
|
| 141 |
+
def resolve_handoff(self, handoff_id: str, status: str) -> bool:
|
| 142 |
+
if handoff_id in self._pending_handoffs:
|
| 143 |
+
h = self._pending_handoffs[handoff_id]
|
| 144 |
+
self._pending_handoffs[handoff_id] = Handoff(
|
| 145 |
+
handoff_id=h.handoff_id,
|
| 146 |
+
expert_id=h.expert_id,
|
| 147 |
+
query=h.query,
|
| 148 |
+
created_at=h.created_at,
|
| 149 |
+
resolved_at=time.time(),
|
| 150 |
+
status=status,
|
| 151 |
+
thread_id=h.thread_id,
|
| 152 |
+
)
|
| 153 |
+
return True
|
| 154 |
+
return False
|
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Federated metrics aggregation (X07)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import logging
|
| 6 |
+
import math
|
| 7 |
+
import time
|
| 8 |
+
from collections import defaultdict, deque
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
# ── Dataclasses ───────────────────────────────────────────────────────────────
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class NodeMetricsTick:
|
| 19 |
+
"""Single per-node metrics sample."""
|
| 20 |
+
|
| 21 |
+
node_id: str
|
| 22 |
+
community_id: str
|
| 23 |
+
tick_at: float
|
| 24 |
+
|
| 25 |
+
active_capabilities: int = 0
|
| 26 |
+
events_per_min: float = 0.0
|
| 27 |
+
peers_online: int = 0
|
| 28 |
+
|
| 29 |
+
llm_requests_total: int = 0
|
| 30 |
+
rag_requests_total: int = 0
|
| 31 |
+
|
| 32 |
+
gpu_memory_mb: float | None = None
|
| 33 |
+
cpu_percent: float = 0.0
|
| 34 |
+
memory_mb: float = 0.0
|
| 35 |
+
|
| 36 |
+
online_seconds: int = 0
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass
|
| 40 |
+
class CommunityMetrics:
|
| 41 |
+
"""Aggregated metrics across all members of a community (full detail)."""
|
| 42 |
+
|
| 43 |
+
community_id: str
|
| 44 |
+
member_count: int
|
| 45 |
+
online_count: int
|
| 46 |
+
|
| 47 |
+
events_per_min_total: float
|
| 48 |
+
capabilities_total: int
|
| 49 |
+
|
| 50 |
+
ticks: list[NodeMetricsTick] # per-node detail
|
| 51 |
+
sampled_at: float
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@dataclass
|
| 55 |
+
class AggregatedSnapshot:
|
| 56 |
+
"""
|
| 57 |
+
Anonymised/banded aggregate for federated peers (less information
|
| 58 |
+
at greater distance per X07 design rule).
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
+
community_id: str
|
| 62 |
+
member_count_band: str # e.g. "10-20"
|
| 63 |
+
online_count_band: str
|
| 64 |
+
events_per_min_band: str
|
| 65 |
+
capabilities_count: int
|
| 66 |
+
federation_links_count: int
|
| 67 |
+
sampled_at: float
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _band(value: float, steps: list[int]) -> str:
|
| 74 |
+
"""Return a band string like '10-20' for *value* given boundary *steps*."""
|
| 75 |
+
for i, upper in enumerate(steps):
|
| 76 |
+
lower = steps[i - 1] if i > 0 else 0
|
| 77 |
+
if value < upper:
|
| 78 |
+
return f"{lower}-{upper}"
|
| 79 |
+
last = steps[-1] if steps else 0
|
| 80 |
+
return f"{last}+"
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
_MEMBER_BANDS = [5, 10, 20, 50, 100, 250, 500, 1000]
|
| 84 |
+
_ONLINE_BANDS = [2, 5, 10, 25, 50, 100, 250, 500]
|
| 85 |
+
_EPM_BANDS = [10, 50, 100, 500, 1000, 5000, 10000]
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _collect_system_metrics() -> dict[str, float]:
|
| 89 |
+
"""Snapshot CPU / memory using psutil if available; otherwise zeros."""
|
| 90 |
+
try:
|
| 91 |
+
import psutil # type: ignore[import]
|
| 92 |
+
cpu = psutil.cpu_percent(interval=None)
|
| 93 |
+
mem = psutil.virtual_memory().used / (1024 * 1024)
|
| 94 |
+
return {"cpu_percent": cpu, "memory_mb": mem}
|
| 95 |
+
except ImportError:
|
| 96 |
+
return {"cpu_percent": 0.0, "memory_mb": 0.0}
|
| 97 |
+
except Exception:
|
| 98 |
+
return {"cpu_percent": 0.0, "memory_mb": 0.0}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _collect_gpu_memory() -> float | None:
|
| 102 |
+
"""Return GPU memory usage in MB if pynvml is available."""
|
| 103 |
+
try:
|
| 104 |
+
import pynvml # type: ignore[import]
|
| 105 |
+
pynvml.nvmlInit()
|
| 106 |
+
handle = pynvml.nvmlDeviceGetHandleByIndex(0)
|
| 107 |
+
info = pynvml.nvmlDeviceGetMemoryInfo(handle)
|
| 108 |
+
return info.used / (1024 * 1024)
|
| 109 |
+
except Exception:
|
| 110 |
+
return None
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# ── FederatedMetricsExporter ──────────────────────────────────────────────────
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class FederatedMetricsExporter:
|
| 117 |
+
"""
|
| 118 |
+
Snapshots local metrics and publishes them to the community bus topic
|
| 119 |
+
and optionally to an OTLP collector.
|
| 120 |
+
"""
|
| 121 |
+
|
| 122 |
+
def __init__(
|
| 123 |
+
self,
|
| 124 |
+
node_id: str = "",
|
| 125 |
+
community_id: str = "",
|
| 126 |
+
bus: Any = None,
|
| 127 |
+
) -> None:
|
| 128 |
+
self._node_id = node_id
|
| 129 |
+
self._community_id = community_id
|
| 130 |
+
self._bus = bus
|
| 131 |
+
self._start_time: float = time.time()
|
| 132 |
+
|
| 133 |
+
def collect_tick(self, bus: Any = None) -> NodeMetricsTick:
|
| 134 |
+
"""Snapshot current metrics into a :class:`NodeMetricsTick`."""
|
| 135 |
+
_bus = bus or self._bus
|
| 136 |
+
sys_metrics = _collect_system_metrics()
|
| 137 |
+
|
| 138 |
+
# Collect capability count from bus
|
| 139 |
+
active_caps = 0
|
| 140 |
+
if _bus is not None:
|
| 141 |
+
try:
|
| 142 |
+
caps = _bus.list_capabilities()
|
| 143 |
+
active_caps = len(caps) if caps else 0
|
| 144 |
+
except Exception:
|
| 145 |
+
pass
|
| 146 |
+
|
| 147 |
+
# Collect request counters from prometheus registry if available
|
| 148 |
+
llm_total = 0
|
| 149 |
+
rag_total = 0
|
| 150 |
+
try:
|
| 151 |
+
from hearthnet.observability.metrics import _STD # noqa: PLC0415
|
| 152 |
+
llm_counter = _STD.get("hearthnet_llm_requests_total")
|
| 153 |
+
rag_counter = _STD.get("hearthnet_rag_requests_total")
|
| 154 |
+
if llm_counter is not None and hasattr(llm_counter, "_value"):
|
| 155 |
+
llm_total = int(llm_counter._value.get() or 0)
|
| 156 |
+
if rag_counter is not None and hasattr(rag_counter, "_value"):
|
| 157 |
+
rag_total = int(rag_counter._value.get() or 0)
|
| 158 |
+
except Exception:
|
| 159 |
+
pass
|
| 160 |
+
|
| 161 |
+
online_secs = int(time.time() - self._start_time)
|
| 162 |
+
|
| 163 |
+
return NodeMetricsTick(
|
| 164 |
+
node_id=self._node_id,
|
| 165 |
+
community_id=self._community_id,
|
| 166 |
+
tick_at=time.time(),
|
| 167 |
+
active_capabilities=active_caps,
|
| 168 |
+
events_per_min=0.0, # filled by aggregator from event log
|
| 169 |
+
peers_online=0, # filled by aggregator from peer registry
|
| 170 |
+
llm_requests_total=llm_total,
|
| 171 |
+
rag_requests_total=rag_total,
|
| 172 |
+
gpu_memory_mb=_collect_gpu_memory(),
|
| 173 |
+
cpu_percent=sys_metrics["cpu_percent"],
|
| 174 |
+
memory_mb=sys_metrics["memory_mb"],
|
| 175 |
+
online_seconds=online_secs,
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
async def push_to_community(self, tick: NodeMetricsTick, bus: Any = None) -> None:
|
| 179 |
+
"""Publish *tick* to the bus topic ``observability.metrics.tick.<node_id>``."""
|
| 180 |
+
_bus = bus or self._bus
|
| 181 |
+
if _bus is None:
|
| 182 |
+
logger.debug("FederatedMetricsExporter.push_to_community: no bus configured")
|
| 183 |
+
return
|
| 184 |
+
topic = f"observability.metrics.tick.{tick.node_id}"
|
| 185 |
+
payload: dict[str, Any] = {
|
| 186 |
+
"node_id": tick.node_id,
|
| 187 |
+
"community_id": tick.community_id,
|
| 188 |
+
"tick_at": tick.tick_at,
|
| 189 |
+
"active_capabilities": tick.active_capabilities,
|
| 190 |
+
"events_per_min": tick.events_per_min,
|
| 191 |
+
"peers_online": tick.peers_online,
|
| 192 |
+
"llm_requests_total": tick.llm_requests_total,
|
| 193 |
+
"rag_requests_total": tick.rag_requests_total,
|
| 194 |
+
"gpu_memory_mb": tick.gpu_memory_mb,
|
| 195 |
+
"cpu_percent": tick.cpu_percent,
|
| 196 |
+
"memory_mb": tick.memory_mb,
|
| 197 |
+
"online_seconds": tick.online_seconds,
|
| 198 |
+
}
|
| 199 |
+
try:
|
| 200 |
+
result = _bus.call(
|
| 201 |
+
"bus.publish",
|
| 202 |
+
(1, 0),
|
| 203 |
+
{"topic": topic, "event": "metrics_tick", "data": payload},
|
| 204 |
+
)
|
| 205 |
+
if asyncio.iscoroutine(result):
|
| 206 |
+
await result
|
| 207 |
+
except Exception as exc:
|
| 208 |
+
logger.warning("FederatedMetricsExporter.push_to_community failed: %s", exc)
|
| 209 |
+
|
| 210 |
+
async def push_otlp(self, endpoint: str, tick: NodeMetricsTick) -> None:
|
| 211 |
+
"""
|
| 212 |
+
Export *tick* via OTLP HTTP. Requires opentelemetry-exporter-otlp-proto-http.
|
| 213 |
+
|
| 214 |
+
Delegates to :class:`OtlpExporter`.
|
| 215 |
+
"""
|
| 216 |
+
try:
|
| 217 |
+
from hearthnet.observability.otlp_export import OtlpExporter # noqa: PLC0415
|
| 218 |
+
exporter = OtlpExporter(endpoint)
|
| 219 |
+
await exporter.export_metrics(tick)
|
| 220 |
+
except ImportError:
|
| 221 |
+
logger.debug("push_otlp: opentelemetry not installed — skipping")
|
| 222 |
+
except Exception as exc:
|
| 223 |
+
logger.warning("FederatedMetricsExporter.push_otlp failed: %s", exc)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
# ── MetricsAggregator ─────────────────────────────────────────────────────────
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
class MetricsAggregator:
|
| 230 |
+
"""
|
| 231 |
+
Receives NodeMetricsTick events from all community members and builds
|
| 232 |
+
community-level and federated snapshots.
|
| 233 |
+
"""
|
| 234 |
+
|
| 235 |
+
def __init__(
|
| 236 |
+
self,
|
| 237 |
+
community_id: str,
|
| 238 |
+
max_ticks_per_node: int = 60,
|
| 239 |
+
) -> None:
|
| 240 |
+
self._community_id = community_id
|
| 241 |
+
self._max_ticks = max_ticks_per_node
|
| 242 |
+
# node_id → deque of ticks (newest last)
|
| 243 |
+
self._ticks: dict[str, deque[NodeMetricsTick]] = defaultdict(
|
| 244 |
+
lambda: deque(maxlen=self._max_ticks)
|
| 245 |
+
)
|
| 246 |
+
self._federation_links: dict[str, int] = {} # peer_community_id → count
|
| 247 |
+
|
| 248 |
+
def apply_tick(self, tick: NodeMetricsTick) -> None:
|
| 249 |
+
"""Incorporate a new tick from a community member."""
|
| 250 |
+
self._ticks[tick.node_id].append(tick)
|
| 251 |
+
|
| 252 |
+
def community_snapshot(self) -> CommunityMetrics:
|
| 253 |
+
"""Return the latest community-wide aggregate."""
|
| 254 |
+
now = time.time()
|
| 255 |
+
latest_ticks: list[NodeMetricsTick] = []
|
| 256 |
+
online_cutoff = now - 120 # consider online if tick within 2 min
|
| 257 |
+
|
| 258 |
+
for node_deque in self._ticks.values():
|
| 259 |
+
if node_deque:
|
| 260 |
+
latest_ticks.append(node_deque[-1])
|
| 261 |
+
|
| 262 |
+
online = [t for t in latest_ticks if t.tick_at >= online_cutoff]
|
| 263 |
+
total_epm = sum(t.events_per_min for t in online)
|
| 264 |
+
total_caps = sum(t.active_capabilities for t in online)
|
| 265 |
+
|
| 266 |
+
return CommunityMetrics(
|
| 267 |
+
community_id=self._community_id,
|
| 268 |
+
member_count=len(self._ticks),
|
| 269 |
+
online_count=len(online),
|
| 270 |
+
events_per_min_total=total_epm,
|
| 271 |
+
capabilities_total=total_caps,
|
| 272 |
+
ticks=list(latest_ticks),
|
| 273 |
+
sampled_at=now,
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
def federated_snapshot(self, peer_community_id: str) -> AggregatedSnapshot:
|
| 277 |
+
"""
|
| 278 |
+
Return a banded/anonymised snapshot suitable for sharing with a
|
| 279 |
+
federated peer community.
|
| 280 |
+
"""
|
| 281 |
+
snap = self.community_snapshot()
|
| 282 |
+
fed_links = len(self._federation_links)
|
| 283 |
+
|
| 284 |
+
return AggregatedSnapshot(
|
| 285 |
+
community_id=self._community_id,
|
| 286 |
+
member_count_band=_band(snap.member_count, _MEMBER_BANDS),
|
| 287 |
+
online_count_band=_band(snap.online_count, _ONLINE_BANDS),
|
| 288 |
+
events_per_min_band=_band(snap.events_per_min_total, _EPM_BANDS),
|
| 289 |
+
capabilities_count=snap.capabilities_total,
|
| 290 |
+
federation_links_count=fed_links,
|
| 291 |
+
sampled_at=snap.sampled_at,
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
def record_federation_link(self, peer_community_id: str) -> None:
|
| 295 |
+
"""Track that we have an active federation link to *peer_community_id*."""
|
| 296 |
+
self._federation_links[peer_community_id] = self._federation_links.get(
|
| 297 |
+
peer_community_id, 0
|
| 298 |
+
) + 1
|
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OTLP metrics and trace export (X07 optional OpenTelemetry integration)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
from typing import TYPE_CHECKING, Any
|
| 6 |
+
|
| 7 |
+
if TYPE_CHECKING:
|
| 8 |
+
from hearthnet.observability.federated import NodeMetricsTick
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
# Optional OpenTelemetry imports
|
| 13 |
+
try:
|
| 14 |
+
from opentelemetry import metrics as otel_metrics # type: ignore[import]
|
| 15 |
+
from opentelemetry.sdk.metrics import MeterProvider # type: ignore[import]
|
| 16 |
+
from opentelemetry.sdk.metrics.export import ( # type: ignore[import]
|
| 17 |
+
PeriodicExportingMetricReader,
|
| 18 |
+
)
|
| 19 |
+
from opentelemetry.exporter.otlp.proto.http.metric_exporter import ( # type: ignore[import]
|
| 20 |
+
OTLPMetricExporter,
|
| 21 |
+
)
|
| 22 |
+
HAS_OTEL_METRICS = True
|
| 23 |
+
except ImportError:
|
| 24 |
+
HAS_OTEL_METRICS = False
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
from opentelemetry.sdk.trace import TracerProvider # type: ignore[import]
|
| 28 |
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor # type: ignore[import]
|
| 29 |
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( # type: ignore[import]
|
| 30 |
+
OTLPSpanExporter,
|
| 31 |
+
)
|
| 32 |
+
HAS_OTEL_TRACES = True
|
| 33 |
+
except ImportError:
|
| 34 |
+
HAS_OTEL_TRACES = False
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class OtlpExporter:
|
| 38 |
+
"""
|
| 39 |
+
Sends HearthNet metrics and traces to an OTLP HTTP collector.
|
| 40 |
+
|
| 41 |
+
Both opentelemetry-sdk and opentelemetry-exporter-otlp-proto-http are
|
| 42 |
+
optional. All methods return False / empty and log a debug message
|
| 43 |
+
when the libraries are not installed.
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
def __init__(self, endpoint: str) -> None:
|
| 47 |
+
self._endpoint = endpoint.rstrip("/")
|
| 48 |
+
self._meter_provider: Any = None
|
| 49 |
+
self._tracer_provider: Any = None
|
| 50 |
+
|
| 51 |
+
def _get_meter_provider(self) -> Any:
|
| 52 |
+
"""Lazily initialise the OTLP MeterProvider."""
|
| 53 |
+
if self._meter_provider is not None:
|
| 54 |
+
return self._meter_provider
|
| 55 |
+
if not HAS_OTEL_METRICS:
|
| 56 |
+
return None
|
| 57 |
+
try:
|
| 58 |
+
exporter = OTLPMetricExporter(endpoint=f"{self._endpoint}/v1/metrics") # type: ignore[possibly-undefined]
|
| 59 |
+
reader = PeriodicExportingMetricReader(exporter, export_interval_millis=60_000) # type: ignore[possibly-undefined]
|
| 60 |
+
provider = MeterProvider(metric_readers=[reader]) # type: ignore[possibly-undefined]
|
| 61 |
+
self._meter_provider = provider
|
| 62 |
+
return provider
|
| 63 |
+
except Exception as exc:
|
| 64 |
+
logger.warning("OtlpExporter: failed to create MeterProvider: %s", exc)
|
| 65 |
+
return None
|
| 66 |
+
|
| 67 |
+
def _get_tracer_provider(self) -> Any:
|
| 68 |
+
"""Lazily initialise the OTLP TracerProvider."""
|
| 69 |
+
if self._tracer_provider is not None:
|
| 70 |
+
return self._tracer_provider
|
| 71 |
+
if not HAS_OTEL_TRACES:
|
| 72 |
+
return None
|
| 73 |
+
try:
|
| 74 |
+
exporter = OTLPSpanExporter(endpoint=f"{self._endpoint}/v1/traces") # type: ignore[possibly-undefined]
|
| 75 |
+
provider = TracerProvider() # type: ignore[possibly-undefined]
|
| 76 |
+
provider.add_span_processor(SimpleSpanProcessor(exporter)) # type: ignore[possibly-undefined]
|
| 77 |
+
self._tracer_provider = provider
|
| 78 |
+
return provider
|
| 79 |
+
except Exception as exc:
|
| 80 |
+
logger.warning("OtlpExporter: failed to create TracerProvider: %s", exc)
|
| 81 |
+
return None
|
| 82 |
+
|
| 83 |
+
async def export_metrics(self, tick: NodeMetricsTick) -> bool:
|
| 84 |
+
"""
|
| 85 |
+
Export a :class:`NodeMetricsTick` as OTLP gauge metrics.
|
| 86 |
+
|
| 87 |
+
Returns True if data was exported, False if opentelemetry is not
|
| 88 |
+
installed or an error occurred.
|
| 89 |
+
"""
|
| 90 |
+
if not HAS_OTEL_METRICS:
|
| 91 |
+
logger.debug(
|
| 92 |
+
"OtlpExporter.export_metrics: opentelemetry not installed — skipping"
|
| 93 |
+
)
|
| 94 |
+
return False
|
| 95 |
+
provider = self._get_meter_provider()
|
| 96 |
+
if provider is None:
|
| 97 |
+
return False
|
| 98 |
+
try:
|
| 99 |
+
meter = provider.get_meter("hearthnet") # type: ignore[union-attr]
|
| 100 |
+
# Record gauges for each numeric field on the tick
|
| 101 |
+
fields: dict[str, float | int | None] = {
|
| 102 |
+
"active_capabilities": tick.active_capabilities,
|
| 103 |
+
"events_per_min": tick.events_per_min,
|
| 104 |
+
"peers_online": tick.peers_online,
|
| 105 |
+
"llm_requests_total": tick.llm_requests_total,
|
| 106 |
+
"rag_requests_total": tick.rag_requests_total,
|
| 107 |
+
"cpu_percent": tick.cpu_percent,
|
| 108 |
+
"memory_mb": tick.memory_mb,
|
| 109 |
+
"online_seconds": tick.online_seconds,
|
| 110 |
+
"gpu_memory_mb": tick.gpu_memory_mb,
|
| 111 |
+
}
|
| 112 |
+
attrs = {
|
| 113 |
+
"node_id": tick.node_id,
|
| 114 |
+
"community_id": tick.community_id,
|
| 115 |
+
}
|
| 116 |
+
for name, value in fields.items():
|
| 117 |
+
if value is None:
|
| 118 |
+
continue
|
| 119 |
+
g = meter.create_gauge(
|
| 120 |
+
f"hearthnet_{name}",
|
| 121 |
+
description=f"HearthNet {name}",
|
| 122 |
+
)
|
| 123 |
+
g.set(float(value), attrs)
|
| 124 |
+
return True
|
| 125 |
+
except Exception as exc:
|
| 126 |
+
logger.warning("OtlpExporter.export_metrics error: %s", exc)
|
| 127 |
+
return False
|
| 128 |
+
|
| 129 |
+
async def export_traces(self, spans: list[dict]) -> bool:
|
| 130 |
+
"""
|
| 131 |
+
Export a list of span dicts as OTLP traces.
|
| 132 |
+
|
| 133 |
+
Each span dict should have at minimum: ``name``, ``trace_id``,
|
| 134 |
+
``span_id``, ``start_time``, ``end_time``.
|
| 135 |
+
|
| 136 |
+
Returns True if spans were submitted, False otherwise.
|
| 137 |
+
"""
|
| 138 |
+
if not HAS_OTEL_TRACES:
|
| 139 |
+
logger.debug(
|
| 140 |
+
"OtlpExporter.export_traces: opentelemetry not installed — skipping"
|
| 141 |
+
)
|
| 142 |
+
return False
|
| 143 |
+
provider = self._get_tracer_provider()
|
| 144 |
+
if provider is None:
|
| 145 |
+
return False
|
| 146 |
+
try:
|
| 147 |
+
tracer = provider.get_tracer("hearthnet") # type: ignore[union-attr]
|
| 148 |
+
for span_dict in spans:
|
| 149 |
+
name = span_dict.get("name", "hearthnet.span")
|
| 150 |
+
with tracer.start_as_current_span(name) as span:
|
| 151 |
+
for k, v in span_dict.items():
|
| 152 |
+
if k not in ("name", "trace_id", "span_id", "start_time", "end_time"):
|
| 153 |
+
try:
|
| 154 |
+
span.set_attribute(k, str(v))
|
| 155 |
+
except Exception:
|
| 156 |
+
pass
|
| 157 |
+
return True
|
| 158 |
+
except Exception as exc:
|
| 159 |
+
logger.warning("OtlpExporter.export_traces error: %s", exc)
|
| 160 |
+
return False
|
| 161 |
+
|
| 162 |
+
async def shutdown(self) -> None:
|
| 163 |
+
"""Flush and shut down the underlying providers."""
|
| 164 |
+
if self._meter_provider is not None:
|
| 165 |
+
try:
|
| 166 |
+
self._meter_provider.shutdown() # type: ignore[union-attr]
|
| 167 |
+
except Exception:
|
| 168 |
+
pass
|
| 169 |
+
if self._tracer_provider is not None:
|
| 170 |
+
try:
|
| 171 |
+
self._tracer_provider.shutdown() # type: ignore[union-attr]
|
| 172 |
+
except Exception:
|
| 173 |
+
pass
|
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# hearthnet/relay — client-side relay helpers (M15)
|
| 2 |
+
from __future__ import annotations
|
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Relay client — registers with a relay server for NAT traversal (M15)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
import time
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
# Optional httpx
|
| 13 |
+
try:
|
| 14 |
+
import httpx
|
| 15 |
+
HAS_HTTPX = True
|
| 16 |
+
except ImportError:
|
| 17 |
+
httpx = None # type: ignore[assignment]
|
| 18 |
+
HAS_HTTPX = False
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass(frozen=True)
|
| 22 |
+
class RelayRegistration:
|
| 23 |
+
"""Immutable record of a successful relay registration."""
|
| 24 |
+
|
| 25 |
+
relay_url: str
|
| 26 |
+
node_id: str
|
| 27 |
+
registered_at: float
|
| 28 |
+
expires_at: float
|
| 29 |
+
forwarding_url: str
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class RelayClient:
|
| 33 |
+
"""
|
| 34 |
+
Client-side helper for registering with a HearthNet relay server.
|
| 35 |
+
|
| 36 |
+
All methods degrade gracefully when the relay is unreachable: they log a
|
| 37 |
+
warning and return empty / False rather than raising.
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
def __init__(
|
| 41 |
+
self,
|
| 42 |
+
relay_url: str,
|
| 43 |
+
http_client: object | None = None,
|
| 44 |
+
keypair: object | None = None,
|
| 45 |
+
) -> None:
|
| 46 |
+
self._relay_url = relay_url.rstrip("/")
|
| 47 |
+
self._http_client = http_client # Optional HttpClient instance
|
| 48 |
+
self._keypair = keypair
|
| 49 |
+
self._httpx_client: object | None = None
|
| 50 |
+
self._keepalive_task: asyncio.Task | None = None
|
| 51 |
+
|
| 52 |
+
# ── Internal helpers ──────────────────────────────────────────────────────
|
| 53 |
+
|
| 54 |
+
def _get_httpx(self) -> object:
|
| 55 |
+
"""Return or create a shared httpx.AsyncClient."""
|
| 56 |
+
if not HAS_HTTPX:
|
| 57 |
+
raise ImportError("httpx is required for RelayClient: pip install httpx")
|
| 58 |
+
if self._httpx_client is None:
|
| 59 |
+
import httpx as _httpx # noqa: PLC0415
|
| 60 |
+
self._httpx_client = _httpx.AsyncClient(timeout=10.0)
|
| 61 |
+
return self._httpx_client
|
| 62 |
+
|
| 63 |
+
def _sign_payload(self, payload: dict) -> dict:
|
| 64 |
+
"""Attach a signature if a keypair is present."""
|
| 65 |
+
if self._keypair is None:
|
| 66 |
+
return payload
|
| 67 |
+
try:
|
| 68 |
+
if hasattr(self._keypair, "sign"):
|
| 69 |
+
sig = self._keypair.sign(json.dumps(payload, sort_keys=True).encode())
|
| 70 |
+
payload = dict(payload)
|
| 71 |
+
if hasattr(sig, "hex"):
|
| 72 |
+
payload["_sig"] = sig.hex()
|
| 73 |
+
else:
|
| 74 |
+
payload["_sig"] = str(sig)
|
| 75 |
+
except Exception as exc:
|
| 76 |
+
logger.debug("RelayClient._sign_payload: %s", exc)
|
| 77 |
+
return payload
|
| 78 |
+
|
| 79 |
+
async def _post(self, path: str, body: dict) -> dict | None:
|
| 80 |
+
"""POST *body* to relay path. Returns parsed JSON or None on error."""
|
| 81 |
+
url = f"{self._relay_url}{path}"
|
| 82 |
+
try:
|
| 83 |
+
client = self._get_httpx()
|
| 84 |
+
resp = await client.post(url, json=body) # type: ignore[union-attr]
|
| 85 |
+
resp.raise_for_status()
|
| 86 |
+
return resp.json()
|
| 87 |
+
except Exception as exc:
|
| 88 |
+
logger.warning("RelayClient POST %s failed: %s", path, exc)
|
| 89 |
+
return None
|
| 90 |
+
|
| 91 |
+
async def _get(self, path: str) -> dict | list | None:
|
| 92 |
+
"""GET relay path. Returns parsed JSON or None on error."""
|
| 93 |
+
url = f"{self._relay_url}{path}"
|
| 94 |
+
try:
|
| 95 |
+
client = self._get_httpx()
|
| 96 |
+
resp = await client.get(url) # type: ignore[union-attr]
|
| 97 |
+
resp.raise_for_status()
|
| 98 |
+
return resp.json()
|
| 99 |
+
except Exception as exc:
|
| 100 |
+
logger.warning("RelayClient GET %s failed: %s", path, exc)
|
| 101 |
+
return None
|
| 102 |
+
|
| 103 |
+
# ── Public API ────────────────────────────────────────────────────────────
|
| 104 |
+
|
| 105 |
+
async def register(
|
| 106 |
+
self,
|
| 107 |
+
node_id: str,
|
| 108 |
+
endpoints: list[str],
|
| 109 |
+
community_id: str,
|
| 110 |
+
ttl: int = 60,
|
| 111 |
+
) -> RelayRegistration:
|
| 112 |
+
"""
|
| 113 |
+
Register this node with the relay server.
|
| 114 |
+
|
| 115 |
+
Returns a :class:`RelayRegistration`. On failure, returns a
|
| 116 |
+
registration with ``forwarding_url=""`` and ``expires_at`` in the past.
|
| 117 |
+
"""
|
| 118 |
+
now = time.time()
|
| 119 |
+
payload = self._sign_payload(
|
| 120 |
+
{
|
| 121 |
+
"node_id": node_id,
|
| 122 |
+
"endpoints": endpoints,
|
| 123 |
+
"community_id": community_id,
|
| 124 |
+
"ttl": ttl,
|
| 125 |
+
"registered_at": now,
|
| 126 |
+
}
|
| 127 |
+
)
|
| 128 |
+
data = await self._post("/relay/v1/register", payload)
|
| 129 |
+
if data and isinstance(data, dict):
|
| 130 |
+
return RelayRegistration(
|
| 131 |
+
relay_url=self._relay_url,
|
| 132 |
+
node_id=node_id,
|
| 133 |
+
registered_at=now,
|
| 134 |
+
expires_at=data.get("expires_at", now + ttl),
|
| 135 |
+
forwarding_url=data.get("forwarding_url", ""),
|
| 136 |
+
)
|
| 137 |
+
# Degraded: return sentinel with past expiry
|
| 138 |
+
return RelayRegistration(
|
| 139 |
+
relay_url=self._relay_url,
|
| 140 |
+
node_id=node_id,
|
| 141 |
+
registered_at=now,
|
| 142 |
+
expires_at=now - 1,
|
| 143 |
+
forwarding_url="",
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
async def heartbeat(self, node_id: str) -> bool:
|
| 147 |
+
"""Renew the relay registration. Returns True on success."""
|
| 148 |
+
payload = self._sign_payload({"node_id": node_id, "ts": time.time()})
|
| 149 |
+
data = await self._post("/relay/v1/heartbeat", payload)
|
| 150 |
+
return bool(data and data.get("ok"))
|
| 151 |
+
|
| 152 |
+
async def deregister(self, node_id: str) -> bool:
|
| 153 |
+
"""Cleanly remove the relay registration. Returns True on success."""
|
| 154 |
+
payload = self._sign_payload({"node_id": node_id})
|
| 155 |
+
data = await self._post("/relay/v1/deregister", payload)
|
| 156 |
+
return bool(data and data.get("ok"))
|
| 157 |
+
|
| 158 |
+
async def lookup_community(self, community_id: str) -> list[str]:
|
| 159 |
+
"""
|
| 160 |
+
Look up the current bridge endpoint URLs for *community_id*.
|
| 161 |
+
|
| 162 |
+
Returns an empty list on error.
|
| 163 |
+
"""
|
| 164 |
+
data = await self._get(f"/relay/v1/community/{community_id}")
|
| 165 |
+
if data is None:
|
| 166 |
+
return []
|
| 167 |
+
if isinstance(data, list):
|
| 168 |
+
return [str(e) for e in data]
|
| 169 |
+
if isinstance(data, dict):
|
| 170 |
+
# Accept {"endpoints": [...]} envelope
|
| 171 |
+
return [str(e) for e in data.get("endpoints", [])]
|
| 172 |
+
return []
|
| 173 |
+
|
| 174 |
+
async def start_keepalive(
|
| 175 |
+
self,
|
| 176 |
+
node_id: str,
|
| 177 |
+
interval: int = 30,
|
| 178 |
+
) -> asyncio.Task:
|
| 179 |
+
"""
|
| 180 |
+
Start a background asyncio task that sends a heartbeat every
|
| 181 |
+
*interval* seconds. Cancels any previously running keepalive.
|
| 182 |
+
"""
|
| 183 |
+
if self._keepalive_task is not None and not self._keepalive_task.done():
|
| 184 |
+
self._keepalive_task.cancel()
|
| 185 |
+
|
| 186 |
+
async def _loop() -> None:
|
| 187 |
+
while True:
|
| 188 |
+
await asyncio.sleep(interval)
|
| 189 |
+
ok = await self.heartbeat(node_id)
|
| 190 |
+
if not ok:
|
| 191 |
+
logger.warning("RelayClient keepalive heartbeat failed for %s", node_id)
|
| 192 |
+
|
| 193 |
+
self._keepalive_task = asyncio.create_task(_loop())
|
| 194 |
+
return self._keepalive_task
|
| 195 |
+
|
| 196 |
+
async def close(self) -> None:
|
| 197 |
+
"""Cancel keepalive and close the internal httpx client."""
|
| 198 |
+
if self._keepalive_task is not None and not self._keepalive_task.done():
|
| 199 |
+
self._keepalive_task.cancel()
|
| 200 |
+
try:
|
| 201 |
+
await self._keepalive_task
|
| 202 |
+
except asyncio.CancelledError:
|
| 203 |
+
pass
|
| 204 |
+
if self._httpx_client is not None:
|
| 205 |
+
try:
|
| 206 |
+
await self._httpx_client.aclose() # type: ignore[union-attr]
|
| 207 |
+
except Exception:
|
| 208 |
+
pass
|
| 209 |
+
self._httpx_client = None
|
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Push token registry for relay-mediated mobile notifications (M15)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
import time
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
try:
|
| 11 |
+
import httpx
|
| 12 |
+
HAS_HTTPX = True
|
| 13 |
+
except ImportError:
|
| 14 |
+
httpx = None # type: ignore[assignment]
|
| 15 |
+
HAS_HTTPX = False
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class PushTokenRecord:
|
| 20 |
+
"""Record of a mobile push token registered with a relay."""
|
| 21 |
+
|
| 22 |
+
node_id: str
|
| 23 |
+
platform: str # "apns" | "fcm" | "generic"
|
| 24 |
+
device_token: str
|
| 25 |
+
relay_url: str
|
| 26 |
+
registered_at: float
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class PushSubscriber:
|
| 30 |
+
"""
|
| 31 |
+
Registers and unregisters APNs / FCM device tokens with a relay server.
|
| 32 |
+
|
| 33 |
+
Degrades gracefully when the relay is unreachable.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
def __init__(
|
| 37 |
+
self,
|
| 38 |
+
relay_url: str,
|
| 39 |
+
http_client: object | None = None,
|
| 40 |
+
) -> None:
|
| 41 |
+
self._relay_url = relay_url.rstrip("/")
|
| 42 |
+
self._http_client = http_client
|
| 43 |
+
self._httpx_client: object | None = None
|
| 44 |
+
|
| 45 |
+
# ── Internal helpers ──────────────────────────────────────────────────────
|
| 46 |
+
|
| 47 |
+
def _get_httpx(self) -> object:
|
| 48 |
+
if not HAS_HTTPX:
|
| 49 |
+
raise ImportError("httpx is required for PushSubscriber: pip install httpx")
|
| 50 |
+
if self._httpx_client is None:
|
| 51 |
+
import httpx as _httpx # noqa: PLC0415
|
| 52 |
+
self._httpx_client = _httpx.AsyncClient(timeout=10.0)
|
| 53 |
+
return self._httpx_client
|
| 54 |
+
|
| 55 |
+
async def _post(self, path: str, body: dict) -> dict | None:
|
| 56 |
+
url = f"{self._relay_url}{path}"
|
| 57 |
+
try:
|
| 58 |
+
client = self._get_httpx()
|
| 59 |
+
resp = await client.post(url, json=body) # type: ignore[union-attr]
|
| 60 |
+
resp.raise_for_status()
|
| 61 |
+
return resp.json()
|
| 62 |
+
except Exception as exc:
|
| 63 |
+
logger.warning("PushSubscriber POST %s failed: %s", path, exc)
|
| 64 |
+
return None
|
| 65 |
+
|
| 66 |
+
# ── Public API ────────────────────────────────────────────────────────────
|
| 67 |
+
|
| 68 |
+
async def register_push_token(
|
| 69 |
+
self,
|
| 70 |
+
node_id: str,
|
| 71 |
+
platform: str,
|
| 72 |
+
device_token: str,
|
| 73 |
+
) -> bool:
|
| 74 |
+
"""
|
| 75 |
+
Register a mobile push token with the relay.
|
| 76 |
+
|
| 77 |
+
Returns True on success, False on failure.
|
| 78 |
+
"""
|
| 79 |
+
payload = {
|
| 80 |
+
"node_id": node_id,
|
| 81 |
+
"platform": platform,
|
| 82 |
+
"device_token": device_token,
|
| 83 |
+
"registered_at": time.time(),
|
| 84 |
+
}
|
| 85 |
+
data = await self._post("/relay/v1/push/register", payload)
|
| 86 |
+
return bool(data and data.get("ok"))
|
| 87 |
+
|
| 88 |
+
async def unregister(self, node_id: str, device_token: str) -> bool:
|
| 89 |
+
"""
|
| 90 |
+
Remove a push token registration from the relay.
|
| 91 |
+
|
| 92 |
+
Returns True on success, False on failure.
|
| 93 |
+
"""
|
| 94 |
+
payload = {"node_id": node_id, "device_token": device_token}
|
| 95 |
+
data = await self._post("/relay/v1/push/unregister", payload)
|
| 96 |
+
return bool(data and data.get("ok"))
|
| 97 |
+
|
| 98 |
+
async def close(self) -> None:
|
| 99 |
+
"""Close the internal httpx client."""
|
| 100 |
+
if self._httpx_client is not None:
|
| 101 |
+
try:
|
| 102 |
+
await self._httpx_client.aclose() # type: ignore[union-attr]
|
| 103 |
+
except Exception:
|
| 104 |
+
pass
|
| 105 |
+
self._httpx_client = None
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AuthService — registers auth.token.* capabilities on the bus (M16)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from hearthnet.identity.tokens import (
|
| 7 |
+
CapabilityToken,
|
| 8 |
+
TokenError,
|
| 9 |
+
TokenScope,
|
| 10 |
+
decode_token,
|
| 11 |
+
issue_token,
|
| 12 |
+
verify_token,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class AuthService:
|
| 17 |
+
"""Manages capability token issuance, verification, and revocation.
|
| 18 |
+
|
| 19 |
+
Registers:
|
| 20 |
+
auth.token.issue@1.0
|
| 21 |
+
auth.token.verify@1.0
|
| 22 |
+
auth.token.revoke@1.0
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
name = "auth"
|
| 26 |
+
|
| 27 |
+
def __init__(
|
| 28 |
+
self,
|
| 29 |
+
keypair: Any,
|
| 30 |
+
community_manifest: Any | None = None,
|
| 31 |
+
bus: Any | None = None,
|
| 32 |
+
) -> None:
|
| 33 |
+
self._kp = keypair
|
| 34 |
+
self._community_manifest = community_manifest
|
| 35 |
+
self._bus = bus
|
| 36 |
+
self._revoked_jtis: set[str] = set()
|
| 37 |
+
|
| 38 |
+
# ------------------------------------------------------------------
|
| 39 |
+
# Registration
|
| 40 |
+
# ------------------------------------------------------------------
|
| 41 |
+
|
| 42 |
+
def register(self, bus: Any) -> None:
|
| 43 |
+
"""Register all auth capabilities with the bus Registry."""
|
| 44 |
+
from hearthnet.bus.capability import CapabilityDescriptor
|
| 45 |
+
|
| 46 |
+
self._bus = bus
|
| 47 |
+
registry = getattr(bus, "registry", None)
|
| 48 |
+
if registry is None:
|
| 49 |
+
return
|
| 50 |
+
|
| 51 |
+
descriptors = [
|
| 52 |
+
("auth.token.issue", "1.0", self._handle_issue),
|
| 53 |
+
("auth.token.verify", "1.0", self._handle_verify),
|
| 54 |
+
("auth.token.revoke", "1.0", self._handle_revoke),
|
| 55 |
+
]
|
| 56 |
+
for name, version, handler in descriptors:
|
| 57 |
+
desc = CapabilityDescriptor(
|
| 58 |
+
name=name,
|
| 59 |
+
version=version,
|
| 60 |
+
stability="stable",
|
| 61 |
+
params={},
|
| 62 |
+
max_concurrent=4,
|
| 63 |
+
)
|
| 64 |
+
registry.register_local(desc, handler)
|
| 65 |
+
|
| 66 |
+
# ------------------------------------------------------------------
|
| 67 |
+
# Handlers
|
| 68 |
+
# ------------------------------------------------------------------
|
| 69 |
+
|
| 70 |
+
def _handle_issue(self, params: dict) -> dict:
|
| 71 |
+
"""auth.token.issue@1.0 handler.
|
| 72 |
+
|
| 73 |
+
params: {subject, audience, capabilities: list[str],
|
| 74 |
+
ttl_seconds=3600, issued_via="manual",
|
| 75 |
+
max_uses=None, max_calls_total=None}
|
| 76 |
+
returns: {token: str, expires_at: int}
|
| 77 |
+
"""
|
| 78 |
+
subject = params.get("subject", "")
|
| 79 |
+
audience = params.get("audience", "")
|
| 80 |
+
capabilities = list(params.get("capabilities", []))
|
| 81 |
+
ttl = int(params.get("ttl_seconds", 3600))
|
| 82 |
+
issued_via = str(params.get("issued_via", "manual"))
|
| 83 |
+
max_uses = params.get("max_uses")
|
| 84 |
+
max_calls_total = params.get("max_calls_total")
|
| 85 |
+
|
| 86 |
+
scope = TokenScope(
|
| 87 |
+
capabilities=capabilities,
|
| 88 |
+
max_uses=max_uses,
|
| 89 |
+
max_calls_total=max_calls_total,
|
| 90 |
+
)
|
| 91 |
+
try:
|
| 92 |
+
tok, encoded = issue_token(
|
| 93 |
+
self._kp,
|
| 94 |
+
subject_node_id=subject,
|
| 95 |
+
audience=audience,
|
| 96 |
+
scope=scope,
|
| 97 |
+
ttl_seconds=ttl,
|
| 98 |
+
issued_via=issued_via,
|
| 99 |
+
)
|
| 100 |
+
except TokenError as exc:
|
| 101 |
+
return {"error": str(exc)}
|
| 102 |
+
|
| 103 |
+
return {"token": encoded, "expires_at": tok.exp}
|
| 104 |
+
|
| 105 |
+
def _handle_verify(self, params: dict) -> dict:
|
| 106 |
+
"""auth.token.verify@1.0 handler.
|
| 107 |
+
|
| 108 |
+
params: {token: str}
|
| 109 |
+
returns: {valid: bool, subject: str | None, capabilities: list[str], expires_at: int}
|
| 110 |
+
"""
|
| 111 |
+
text = params.get("token", "")
|
| 112 |
+
try:
|
| 113 |
+
tok = decode_token(text)
|
| 114 |
+
except TokenError as exc:
|
| 115 |
+
return {"valid": False, "subject": None, "capabilities": [], "expires_at": 0,
|
| 116 |
+
"error": str(exc)}
|
| 117 |
+
|
| 118 |
+
# Check revocation
|
| 119 |
+
if tok.jti in self._revoked_jtis:
|
| 120 |
+
return {"valid": False, "subject": tok.sub, "capabilities": list(tok.scope.capabilities),
|
| 121 |
+
"expires_at": tok.exp, "error": "Token has been revoked"}
|
| 122 |
+
|
| 123 |
+
try:
|
| 124 |
+
verify_token(tok, community_manifest=self._community_manifest)
|
| 125 |
+
except TokenError as exc:
|
| 126 |
+
return {"valid": False, "subject": tok.sub, "capabilities": list(tok.scope.capabilities),
|
| 127 |
+
"expires_at": tok.exp, "error": str(exc)}
|
| 128 |
+
|
| 129 |
+
return {
|
| 130 |
+
"valid": True,
|
| 131 |
+
"subject": tok.sub,
|
| 132 |
+
"capabilities": list(tok.scope.capabilities),
|
| 133 |
+
"expires_at": tok.exp,
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
def _handle_revoke(self, params: dict) -> dict:
|
| 137 |
+
"""auth.token.revoke@1.0 handler.
|
| 138 |
+
|
| 139 |
+
params: {jti: str}
|
| 140 |
+
returns: {revoked: bool}
|
| 141 |
+
"""
|
| 142 |
+
jti = params.get("jti", "")
|
| 143 |
+
if not jti:
|
| 144 |
+
return {"revoked": False, "error": "No jti provided"}
|
| 145 |
+
self._revoked_jtis.add(jti)
|
| 146 |
+
return {"revoked": True}
|
| 147 |
+
|
| 148 |
+
# ------------------------------------------------------------------
|
| 149 |
+
# Direct API (for use without the bus)
|
| 150 |
+
# ------------------------------------------------------------------
|
| 151 |
+
|
| 152 |
+
def issue(
|
| 153 |
+
self,
|
| 154 |
+
subject: str,
|
| 155 |
+
audience: str,
|
| 156 |
+
capabilities: list[str],
|
| 157 |
+
ttl_seconds: int = 3600,
|
| 158 |
+
issued_via: str = "manual",
|
| 159 |
+
) -> tuple[CapabilityToken, str]:
|
| 160 |
+
"""Issue a token directly (bypasses the bus)."""
|
| 161 |
+
scope = TokenScope(capabilities=capabilities)
|
| 162 |
+
return issue_token(
|
| 163 |
+
self._kp,
|
| 164 |
+
subject_node_id=subject,
|
| 165 |
+
audience=audience,
|
| 166 |
+
scope=scope,
|
| 167 |
+
ttl_seconds=ttl_seconds,
|
| 168 |
+
issued_via=issued_via,
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
def verify(self, text: str) -> CapabilityToken:
|
| 172 |
+
"""Verify a token string directly. Raises TokenError if invalid."""
|
| 173 |
+
tok = decode_token(text)
|
| 174 |
+
if tok.jti in self._revoked_jtis:
|
| 175 |
+
raise TokenError(f"Token {tok.jti!r} has been revoked")
|
| 176 |
+
verify_token(tok, community_manifest=self._community_manifest)
|
| 177 |
+
return tok
|
| 178 |
+
|
| 179 |
+
def revoke(self, jti: str) -> None:
|
| 180 |
+
"""Revoke a token by JTI (in-memory, not persisted across restart)."""
|
| 181 |
+
self._revoked_jtis.add(jti)
|