GitHub Actions commited on
Commit
4cd8837
·
1 Parent(s): 31c93b1

feat: Phase 2 (M14-M25, X05-X07) + Phase 3 experimental (M26-M31) + E2E tests + docs

Browse files

Phase 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)

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .playwright-mcp/page-2026-06-10T14-22-38-882Z.yml +3 -0
  2. .playwright-mcp/page-2026-06-10T14-27-49-510Z.yml +60 -0
  3. .playwright-mcp/page-2026-06-10T14-28-44-543Z.yml +67 -0
  4. .playwright-mcp/page-2026-06-10T14-28-52-588Z.yml +53 -0
  5. .playwright-mcp/page-2026-06-10T14-29-00-723Z.yml +110 -0
  6. README.md +176 -279
  7. docs/impl_ref.md +1634 -0
  8. docs/screenshots/ui-ask-tab.png +0 -0
  9. docs/screenshots/ui-chat-tab.png +0 -0
  10. docs/screenshots/ui-emergency-tab.png +0 -0
  11. docs/screenshots/ui-marketplace-tab.png +0 -0
  12. docs/screenshots/ui-settings-tab.png +0 -0
  13. hearthnet/civdef/__init__.py +6 -0
  14. hearthnet/civdef/service.py +209 -0
  15. hearthnet/config.py +14 -0
  16. hearthnet/constants.py +13 -0
  17. hearthnet/crypto/__init__.py +5 -0
  18. hearthnet/crypto/envelope.py +103 -0
  19. hearthnet/crypto/kem.py +253 -0
  20. hearthnet/crypto/prekeys.py +139 -0
  21. hearthnet/crypto/ratchet.py +247 -0
  22. hearthnet/dht/__init__.py +0 -0
  23. hearthnet/dht/bootstrap.py +54 -0
  24. hearthnet/dht/kademlia.py +136 -0
  25. hearthnet/distributed_inference/__init__.py +11 -0
  26. hearthnet/distributed_inference/pipeline.py +97 -0
  27. hearthnet/distributed_inference/shard.py +86 -0
  28. hearthnet/evidence/__init__.py +6 -0
  29. hearthnet/evidence/store.py +131 -0
  30. hearthnet/federation/__init__.py +1 -0
  31. hearthnet/federation/manifest.py +266 -0
  32. hearthnet/federation/peering.py +234 -0
  33. hearthnet/federation/service.py +164 -0
  34. hearthnet/fedlearn/__init__.py +6 -0
  35. hearthnet/fedlearn/coordinator.py +109 -0
  36. hearthnet/identity/tokens.py +219 -23
  37. hearthnet/lora/__init__.py +6 -0
  38. hearthnet/lora/service.py +119 -0
  39. hearthnet/mobile/__init__.py +9 -0
  40. hearthnet/mobile/invite.py +168 -0
  41. hearthnet/mobile/push_authority.py +238 -0
  42. hearthnet/moe/__init__.py +6 -0
  43. hearthnet/moe/router.py +154 -0
  44. hearthnet/observability/federated.py +298 -0
  45. hearthnet/observability/otlp_export.py +173 -0
  46. hearthnet/relay/__init__.py +2 -0
  47. hearthnet/relay/client.py +209 -0
  48. hearthnet/relay/push_subscriber.py +105 -0
  49. hearthnet/services/auth/__init__.py +1 -0
  50. hearthnet/services/auth/service.py +181 -0
.playwright-mcp/page-2026-06-10T14-22-38-882Z.yml ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ - generic [ref=e5]:
2
+ - img [ref=e9]
3
+ - paragraph [ref=e20]: Laden...
.playwright-mcp/page-2026-06-10T14-27-49-510Z.yml ADDED
@@ -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]
.playwright-mcp/page-2026-06-10T14-28-44-543Z.yml ADDED
@@ -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]
.playwright-mcp/page-2026-06-10T14-28-52-588Z.yml ADDED
@@ -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]
.playwright-mcp/page-2026-06-10T14-29-00-723Z.yml ADDED
@@ -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]
README.md CHANGED
@@ -1,11 +1,11 @@
1
- ---
2
  title: HearthNet
3
- emoji: 🐢
4
  colorFrom: purple
5
  colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.16.0
8
- python_version: '3.12'
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
- <img src="assets/banner.png" alt="HearthNet Banner" width="100%">
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
- ## 🚨 The Problem
31
-
32
- Today's AI depends on centralized cloud infrastructure.
33
 
34
- When the internet goes down, so does access to:
35
 
36
- * 🤖 AI assistants
37
- * 📚 Knowledge bases
38
- * 💬 Communication platforms
39
- * 🛒 Marketplaces
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
- # 🔥 Introducing HearthNet
49
-
50
- **HearthNet** transforms the computers already around you into a resilient local AI network.
51
 
52
- Your gaming PC.
53
 
54
- Your neighbor's old laptop.
55
 
56
- A Raspberry Pi in a community center.
57
 
58
- An unused workstation in a local business.
59
 
60
- Together, they become a shared AI cooperative.
61
 
62
- No centralized server required.
63
 
64
- No cloud dependency required.
65
-
66
- No single point of failure.
67
-
68
- ---
69
 
70
- ## 🎬 Imagine This Scenario
71
 
72
- A storm damages the internet connection for an entire neighborhood.
73
 
74
- Most online services become unavailable.
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
- # Key Features
97
-
98
- ## 🤖 Distributed AI Inference
99
-
100
- Every node advertises its capabilities.
101
-
102
- A gaming PC might offer:
103
 
104
- * Large Language Models
105
- * Vision Models
106
- * Embeddings
 
107
 
108
- A Raspberry Pi might offer:
 
109
 
110
- * Message relaying
111
- * Local storage
112
- * Discovery services
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
- ## 🛡️ Internet-Outage Resilience
145
 
146
- When the internet is available:
 
 
147
 
148
- ```text
149
- Community ↔ Internet ↔ Cloud Services
150
  ```
151
 
152
- When the internet disappears:
153
 
154
- ```text
155
- Community Community ↔ Community
 
 
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
- ## 🛒 Community Marketplace
176
 
177
- Buy.
178
-
179
- Sell.
180
-
181
- Trade.
182
-
183
- Share resources.
184
-
185
- Even during connectivity disruptions.
186
-
187
- ---
188
-
189
- # 🏗️ Architecture
190
-
191
- ```text
192
- ┌───────────────────────────┐
193
- │ Frontend
194
- └──���──────────┬─────────────┘
195
-
196
-
197
- ┌───────────────────────────┐
198
- │ Capability Bus │
199
- └─────────────┬─────────────┘
200
-
201
- ┌──────────┼──────────┐
202
- ▼ ▼ ▼
203
-
204
- ┌────────┐ ┌────────┐ ┌────────┐
205
- LLM │ │ RAG │ │ Chat │
206
- │ Node │ │ Node │ │ Node │
207
- └────────┘ └────────┘ └────────┘
208
-
209
- ▲ ▲ ▲
210
-
211
- ┌────────┐ ┌────────┐ ┌────────┐
212
- │Laptop A│ │Laptop B│ │Pi Zero │
213
- └────────┘ └────────┘ └────────┘
214
  ```
215
 
216
  ---
217
 
218
- # 🚀 Hackathon Goals
219
-
220
- Our hackathon MVP demonstrates:
221
-
222
- ### Phase 1
223
-
224
- * [x] Node discovery
225
- * [x] Capability registration
226
- * [x] Local AI inference
227
- * [x] Knowledge retrieval
228
- * [x] Offline operation
229
-
230
- ### Phase 2
231
-
232
- * [ ] Local marketplace
233
- * [ ] Community messaging
234
- * [ ] Health monitoring
235
- * [ ] Distributed storage
236
-
237
- ### Phase 3
238
-
239
- * [ ] Long-distance mesh networking
240
- * [ ] Federated communities
241
- * [ ] Distributed inference
242
- * [ ] Federated learning
243
-
244
- ---
245
-
246
- # 🧠 Why This Matters
247
-
248
- AI is rapidly becoming essential infrastructure.
249
-
250
- Today, that infrastructure is mostly controlled by a handful of organizations.
251
-
252
- HearthNet explores a different future:
253
-
254
- A future where communities own and operate AI together.
255
-
256
- A future where knowledge remains available during outages.
257
-
258
- A future where local resilience is built directly into our digital systems.
259
-
260
- ---
261
-
262
- # 🌍 Potential Use Cases
263
-
264
- ## 🚑 Emergency Response
265
-
266
- Access critical information when external services are unavailable.
267
-
268
- ---
269
-
270
- ## 🏫 Schools
271
-
272
- Run local educational AI systems without constant cloud access.
 
 
273
 
274
  ---
275
 
276
- ## 🌾 Rural Communities
277
 
278
- Provide AI services in areas with limited connectivity.
279
 
280
- ---
281
 
282
- ## 🎪 Events & Festivals
 
 
 
283
 
284
- Create temporary local AI networks for attendees.
285
 
286
  ---
287
 
288
- ## 🏘️ Neighborhoods
289
 
290
- Community knowledge sharing and local services.
 
 
 
 
 
291
 
292
  ---
293
 
294
- # 🛠️ Technology Stack
295
-
296
- ### AI
297
-
298
- * llama.cpp
299
- * Ollama
300
- * Hugging Face Models
301
- * Sentence Transformers
302
-
303
- ### Backend
304
 
305
- * Python
306
- * FastAPI
307
- * WebSockets
308
 
309
- ### Knowledge Layer
 
310
 
311
- * ChromaDB
312
- * FAISS
313
- * Local Embeddings
314
 
315
- ### Infrastructure
 
316
 
317
- * Docker
318
- * Docker Compose
319
-
320
- ### Frontend
321
-
322
- * Gradio / Web UI
323
 
324
  ---
325
 
326
- # 👥 Team
327
-
328
- Built during the Hugging Face Hackathon ❤️
329
-
330
- We believe that the future of AI should be:
331
-
332
- ✅ Open
333
 
334
- Local
335
-
336
- Resilient
337
-
338
- Community-Owned
 
 
339
 
340
  ---
341
 
342
- # 🤝 Join Us
343
-
344
- We're actively looking for:
345
 
346
- * AI Engineers
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
- # 🌟 Vision
358
 
359
- > "The cloud owns AI today.
360
- >
361
- > HearthNet is our bet that communities will own it tomorrow."
 
 
362
 
363
  ---
364
 
365
  <p align="center">
366
- Built with ❤��, ☕, open source, and a slightly unreasonable belief that neighborhoods should have their own AI.
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 &nbsp;•&nbsp; Community-Powered &nbsp;•&nbsp; Resilient by Design &nbsp;•&nbsp; 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
+ ![Ask tab](docs/screenshots/ui-ask-tab.png)
40
 
41
+ ### Local Chat
42
 
43
+ ![Chat tab](docs/screenshots/ui-chat-tab.png)
44
 
45
+ ### Community Marketplace
46
 
47
+ ![Marketplace tab](docs/screenshots/ui-marketplace-tab.png)
48
 
49
+ ### Emergency / Connectivity Status
 
 
 
 
50
 
51
+ ![Emergency tab](docs/screenshots/ui-emergency-tab.png)
52
 
53
+ ### Settings & Identity
54
 
55
+ ![Settings tab](docs/screenshots/ui-settings-tab.png)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>
docs/impl_ref.md ADDED
@@ -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.*
docs/screenshots/ui-ask-tab.png ADDED
docs/screenshots/ui-chat-tab.png ADDED
docs/screenshots/ui-emergency-tab.png ADDED
docs/screenshots/ui-marketplace-tab.png ADDED
docs/screenshots/ui-settings-tab.png ADDED
hearthnet/civdef/__init__.py ADDED
@@ -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"]
hearthnet/civdef/service.py ADDED
@@ -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
+ }
hearthnet/config.py CHANGED
@@ -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 ───────────────────────────────────────────────────────────────
hearthnet/constants.py CHANGED
@@ -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
hearthnet/crypto/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+
4
+ class CryptoError(Exception):
5
+ """Raised for all crypto-layer failures."""
hearthnet/crypto/envelope.py ADDED
@@ -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)
hearthnet/crypto/kem.py ADDED
@@ -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)
hearthnet/crypto/prekeys.py ADDED
@@ -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)
hearthnet/crypto/ratchet.py ADDED
@@ -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)
hearthnet/dht/__init__.py ADDED
File without changes
hearthnet/dht/bootstrap.py ADDED
@@ -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)
hearthnet/dht/kademlia.py ADDED
@@ -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)
hearthnet/distributed_inference/__init__.py ADDED
@@ -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"]
hearthnet/distributed_inference/pipeline.py ADDED
@@ -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
+ ]
hearthnet/distributed_inference/shard.py ADDED
@@ -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
+ }
hearthnet/evidence/__init__.py ADDED
@@ -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"]
hearthnet/evidence/store.py ADDED
@@ -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
+ }
hearthnet/federation/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from __future__ import annotations
hearthnet/federation/manifest.py ADDED
@@ -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
hearthnet/federation/peering.py ADDED
@@ -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)
hearthnet/federation/service.py ADDED
@@ -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()
hearthnet/fedlearn/__init__.py ADDED
@@ -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"]
hearthnet/fedlearn/coordinator.py ADDED
@@ -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
+ }
hearthnet/identity/tokens.py CHANGED
@@ -1,42 +1,238 @@
1
- from __future__ import annotations
2
-
3
- """tokens.py — Phase 2 stub for capability tokens.
4
 
5
- All functions raise NotImplementedError. Implement in Phase 2.
6
  """
 
7
 
 
 
 
8
  from dataclasses import dataclass
9
  from typing import Any
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  @dataclass(frozen=True)
13
  class CapabilityToken:
14
- """Stub for a signed capability token (Phase 2)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
- token_id: str
17
- issuer_node_id: str
18
- subject_node_id: str
19
- capability: str
20
- issued_at: str
21
- expires_at: str
22
- signature: str
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
 
25
  def issue_token(
26
  issuer_kp: Any,
27
  subject_node_id: str,
28
- capability: str,
29
- ttl_seconds: int = 300,
30
- ) -> CapabilityToken:
31
- """Issue a signed capability token. Phase 2 — not yet implemented."""
32
- raise NotImplementedError("CapabilityToken issuance is a Phase 2 feature.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- def revoke_token(token_id: str) -> None:
41
- """Revoke a capability token by ID. Phase 2 — not yet implemented."""
42
- raise NotImplementedError("CapabilityToken revocation is a Phase 2 feature.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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")
hearthnet/lora/__init__.py ADDED
@@ -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"]
hearthnet/lora/service.py ADDED
@@ -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
+ }
hearthnet/mobile/__init__.py ADDED
@@ -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
+ """
hearthnet/mobile/invite.py ADDED
@@ -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
+ )
hearthnet/mobile/push_authority.py ADDED
@@ -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
hearthnet/moe/__init__.py ADDED
@@ -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"]
hearthnet/moe/router.py ADDED
@@ -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
hearthnet/observability/federated.py ADDED
@@ -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
hearthnet/observability/otlp_export.py ADDED
@@ -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
hearthnet/relay/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # hearthnet/relay — client-side relay helpers (M15)
2
+ from __future__ import annotations
hearthnet/relay/client.py ADDED
@@ -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
hearthnet/relay/push_subscriber.py ADDED
@@ -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
hearthnet/services/auth/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from __future__ import annotations
hearthnet/services/auth/service.py ADDED
@@ -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)