prd splitted + contracts
Browse files- docs/00-OVERVIEW.md +366 -0
- docs/CAPABILITY_CONTRACT.md +1332 -0
- docs/GLOSSARY.md +141 -0
- docs/M01-identity.md +372 -0
- docs/M02-discovery.md +264 -0
- docs/M03-bus.md +632 -0
- docs/M04-llm.md +352 -0
- docs/M05-rag.md +325 -0
- docs/M06-marketplace.md +336 -0
- docs/M07-file-blobs.md +357 -0
- docs/M08-ui.md +338 -0
- docs/M09-emergency.md +222 -0
- docs/M10-chat.md +295 -0
- docs/M11-embedding.md +203 -0
- docs/M12-cli.md +394 -0
- docs/M13-onboarding.md +336 -0
- docs/X01-transport.md +379 -0
- docs/X02-events.md +452 -0
- docs/X03-observability.md +215 -0
- docs/X04-config.md +273 -0
docs/00-OVERVIEW.md
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# HearthNet — Spec Set Overview
|
| 2 |
+
|
| 3 |
+
This directory is the implementation-level specification for HearthNet. It supersedes the design-level PRD for any conflict on the wire format, file layout, or API surface.
|
| 4 |
+
|
| 5 |
+
The documents are intentionally redundant in some places so each module spec can be read independently during implementation.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 0. How to read this set
|
| 10 |
+
|
| 11 |
+
Read in this order:
|
| 12 |
+
|
| 13 |
+
1. **00-OVERVIEW.md** (this file) — module map, dependency graph, build order
|
| 14 |
+
2. **GLOSSARY.md** — canonical names for every entity that appears in more than one doc
|
| 15 |
+
3. **CAPABILITY_CONTRACT.md** — the wire-level source of truth; everything else must comply
|
| 16 |
+
4. **cross-cutting/X01..X04** — concerns that touch every module
|
| 17 |
+
5. **modules/M01..M13** — per-module specs in dependency order
|
| 18 |
+
|
| 19 |
+
Cross-references between documents use the shorthand `[M03 §4.2]` (module 3, section 4.2) or `[CONTRACT §7.1]` (the contract document, section 7.1).
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
## 1. Module map
|
| 24 |
+
|
| 25 |
+
### Numbered modules (from PRD v2 §20)
|
| 26 |
+
|
| 27 |
+
| ID | Module | Spec file | Concern |
|
| 28 |
+
|-----|-------------------------|----------------------------------------------|------------------------------------------------------|
|
| 29 |
+
| M01 | Identity & manifests | `modules/M01-identity.md` | Crypto identity, signing, verification, manifests |
|
| 30 |
+
| M02 | Discovery | `modules/M02-discovery.md` | Finding peers on a LAN or via relay |
|
| 31 |
+
| M03 | Capability bus | `modules/M03-bus.md` | Routing requests to capabilities |
|
| 32 |
+
| M04 | LLM service | `modules/M04-llm.md` | Language model inference capabilities |
|
| 33 |
+
| M05 | RAG service | `modules/M05-rag.md` | Retrieval-augmented generation |
|
| 34 |
+
| M06 | Marketplace service | `modules/M06-marketplace.md` | Community posts, offers, requests |
|
| 35 |
+
| M07 | File / blobs | `modules/M07-file-blobs.md` | Content-addressed storage and transfer |
|
| 36 |
+
| M08 | UI | `modules/M08-ui.md` | Gradio dashboard + topology viz + mobile |
|
| 37 |
+
| M09 | Emergency detector | `modules/M09-emergency.md` | Internet up/down detection + mode transitions |
|
| 38 |
+
| M10 | Chat service | `modules/M10-chat.md` | Direct messages + store-and-forward |
|
| 39 |
+
| M11 | Embedding service | `modules/M11-embedding.md` | Text and image embedding capabilities |
|
| 40 |
+
| M12 | CLI | `modules/M12-cli.md` | `hearthnet` command-line entry points |
|
| 41 |
+
| M13 | Onboarding | `modules/M13-onboarding.md` | First-run UX, invite QR, key generation flow |
|
| 42 |
+
|
| 43 |
+
### Cross-cutting modules
|
| 44 |
+
|
| 45 |
+
| ID | Module | Spec file | Concern |
|
| 46 |
+
|-----|-----------------|----------------------------------------|------------------------------------------------------|
|
| 47 |
+
| X01 | Transport | `cross-cutting/X01-transport.md` | HTTP server, HTTP client, TLS, streaming, backpressure |
|
| 48 |
+
| X02 | Events | `cross-cutting/X02-events.md` | Event log, Lamport clocks, snapshots, gossip sync |
|
| 49 |
+
| X03 | Observability | `cross-cutting/X03-observability.md` | Logging, metrics, tracing, self-diagnostics |
|
| 50 |
+
| X04 | Config | `cross-cutting/X04-config.md` | Configuration loading and validation |
|
| 51 |
+
|
| 52 |
+
---
|
| 53 |
+
|
| 54 |
+
## 2. Dependency graph
|
| 55 |
+
|
| 56 |
+
```
|
| 57 |
+
┌──────────┐
|
| 58 |
+
│ config │ X04
|
| 59 |
+
└────┬─────┘
|
| 60 |
+
│ (everyone reads it)
|
| 61 |
+
┌──────────────┴──────────────┐
|
| 62 |
+
▼ ▼
|
| 63 |
+
┌─────────┐ ┌─────────────┐
|
| 64 |
+
│ identity│ M01 │observability│ X03
|
| 65 |
+
└────┬────┘ └─────────────┘
|
| 66 |
+
│
|
| 67 |
+
┌────────┼────────────┬────────────┐
|
| 68 |
+
▼ ▼ ▼ ▼
|
| 69 |
+
┌──────┐ ┌─────────┐ ┌─────────┐ ┌──────────┐
|
| 70 |
+
│events│ │discovery│ │transport│ │ blobs │ X02 / M02 / X01 / part-of-M07
|
| 71 |
+
└──┬───┘ └────┬────┘ └────┬────┘ └────┬─────┘
|
| 72 |
+
│ │ │ │
|
| 73 |
+
│ └───────┬───┘ │
|
| 74 |
+
│ ▼ │
|
| 75 |
+
│ ┌─────────┐ │
|
| 76 |
+
│ │ bus │ M03 │
|
| 77 |
+
│ └────┬────┘ │
|
| 78 |
+
│ │ │
|
| 79 |
+
│ ┌─────────┬────┴────┬────────┐ │
|
| 80 |
+
│ ▼ ▼ ▼ ▼ ▼
|
| 81 |
+
│ ┌─────┐ ┌──────┐ ┌────────┐ ┌────────┐
|
| 82 |
+
│ │ llm │ │embed │ │ rag │ │ file │ M04 / M11 / M05 / M07
|
| 83 |
+
│ └─────┘ └──┬───┘ └───┬────┘ └────────┘
|
| 84 |
+
│ │ │
|
| 85 |
+
│ └─────────┘ (rag uses embed)
|
| 86 |
+
│
|
| 87 |
+
│ ┌────────────┐ ┌──────┐
|
| 88 |
+
├─►│ marketplace│ │ chat │ M06 / M10
|
| 89 |
+
│ └────────────┘ └──────┘
|
| 90 |
+
│ │ │
|
| 91 |
+
└────────────┘ │
|
| 92 |
+
│
|
| 93 |
+
┌─────────▼──────────┐
|
| 94 |
+
│ emergency │ M09
|
| 95 |
+
└────────┬───────────┘
|
| 96 |
+
│
|
| 97 |
+
┌────────▼───────────┐
|
| 98 |
+
│ ui │ M08
|
| 99 |
+
└────────────────────┘
|
| 100 |
+
│
|
| 101 |
+
┌────────▼───────────┐
|
| 102 |
+
│ onboarding │ M13
|
| 103 |
+
└────────────────────┘
|
| 104 |
+
│
|
| 105 |
+
┌────────▼───────────┐
|
| 106 |
+
│ cli │ M12
|
| 107 |
+
└────────────────────┘
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
Hard rules:
|
| 111 |
+
|
| 112 |
+
- **No service imports from another service.** Services talk to each other only via the bus.
|
| 113 |
+
- **No layer below the bus imports anything above it.** Transport never imports a service; identity never imports the bus.
|
| 114 |
+
- **The bus does not know what services exist** at build time. Services register themselves at startup.
|
| 115 |
+
- **UI never imports a service directly.** It calls capabilities via the bus.
|
| 116 |
+
|
| 117 |
+
---
|
| 118 |
+
|
| 119 |
+
## 3. File tree (Python package layout)
|
| 120 |
+
|
| 121 |
+
```
|
| 122 |
+
hearthnet/
|
| 123 |
+
├── __init__.py
|
| 124 |
+
├── __main__.py # python -m hearthnet → cli.main()
|
| 125 |
+
├── version.py # __version__ = "0.1.0"
|
| 126 |
+
│
|
| 127 |
+
├── config.py # X04
|
| 128 |
+
│
|
| 129 |
+
├── identity/ # M01
|
| 130 |
+
│ ├── __init__.py
|
| 131 |
+
│ ├── keys.py
|
| 132 |
+
│ ├── manifest.py
|
| 133 |
+
│ └── tokens.py # Phase 2
|
| 134 |
+
│
|
| 135 |
+
├── observability/ # X03
|
| 136 |
+
│ ├── __init__.py
|
| 137 |
+
│ ├── logging.py
|
| 138 |
+
│ ├── metrics.py
|
| 139 |
+
│ ├── tracing.py
|
| 140 |
+
│ └── doctor.py
|
| 141 |
+
│
|
| 142 |
+
├── events/ # X02
|
| 143 |
+
│ ├── __init__.py
|
| 144 |
+
│ ├── log.py
|
| 145 |
+
│ ├── lamport.py
|
| 146 |
+
│ ├── types.py
|
| 147 |
+
│ ├── replay.py
|
| 148 |
+
│ ├── snapshot.py
|
| 149 |
+
│ └── sync.py
|
| 150 |
+
│
|
| 151 |
+
├── discovery/ # M02
|
| 152 |
+
│ ├── __init__.py
|
| 153 |
+
│ ├── mdns.py
|
| 154 |
+
│ ├── udp.py
|
| 155 |
+
│ ├── peers.py # peer registry (in-memory)
|
| 156 |
+
│ └── relay.py # Phase 2
|
| 157 |
+
│
|
| 158 |
+
├── transport/ # X01
|
| 159 |
+
│ ├── __init__.py
|
| 160 |
+
│ ├── server.py
|
| 161 |
+
│ ├── client.py
|
| 162 |
+
│ ├── streams.py
|
| 163 |
+
│ ├── backpressure.py
|
| 164 |
+
│ └── tls.py
|
| 165 |
+
│
|
| 166 |
+
├── blobs/ # M07 (the storage half)
|
| 167 |
+
│ ├── __init__.py
|
| 168 |
+
│ ├── store.py
|
| 169 |
+
│ ├── chunker.py
|
| 170 |
+
│ └── transfer.py
|
| 171 |
+
│
|
| 172 |
+
├── bus/ # M03
|
| 173 |
+
│ ├── __init__.py
|
| 174 |
+
│ ├── capability.py
|
| 175 |
+
│ ├── registry.py
|
| 176 |
+
│ ├── router.py
|
| 177 |
+
│ ├── health.py
|
| 178 |
+
│ ├── schema.py
|
| 179 |
+
│ └── trace.py
|
| 180 |
+
│
|
| 181 |
+
├── services/ # L4
|
| 182 |
+
│ ├── __init__.py
|
| 183 |
+
│ ├── base.py
|
| 184 |
+
│ ├── llm/ # M04
|
| 185 |
+
│ │ ├── __init__.py
|
| 186 |
+
│ │ ├── service.py
|
| 187 |
+
│ │ ├── tokenizers.py
|
| 188 |
+
│ │ └── backends/
|
| 189 |
+
│ │ ├── __init__.py
|
| 190 |
+
│ │ ├── base.py
|
| 191 |
+
│ │ ├── llama_cpp.py
|
| 192 |
+
│ │ ├── ollama.py
|
| 193 |
+
│ │ ├── lmstudio.py
|
| 194 |
+
│ │ ├── hf_api.py
|
| 195 |
+
│ │ └── anthropic_api.py
|
| 196 |
+
│ ├── embedding/ # M11
|
| 197 |
+
│ │ ├── __init__.py
|
| 198 |
+
│ │ ├── service.py
|
| 199 |
+
│ │ └── backends.py
|
| 200 |
+
│ ├── rag/ # M05
|
| 201 |
+
│ │ ├── __init__.py
|
| 202 |
+
│ │ ├── service.py
|
| 203 |
+
│ │ ├── chunker.py
|
| 204 |
+
│ │ ├── ingest.py
|
| 205 |
+
│ │ └── store.py
|
| 206 |
+
│ ├── file/ # M07 (the service half)
|
| 207 |
+
│ │ ├── __init__.py
|
| 208 |
+
│ │ └── service.py
|
| 209 |
+
│ ├── marketplace/ # M06
|
| 210 |
+
│ │ ├── __init__.py
|
| 211 |
+
│ │ ├── service.py
|
| 212 |
+
│ │ ├── post.py
|
| 213 |
+
│ │ └── views.py
|
| 214 |
+
│ └── chat/ # M10
|
| 215 |
+
│ ├── __init__.py
|
| 216 |
+
│ ├── service.py
|
| 217 |
+
│ ├── delivery.py
|
| 218 |
+
│ └── views.py
|
| 219 |
+
│
|
| 220 |
+
├── emergency/ # M09
|
| 221 |
+
│ ├── __init__.py
|
| 222 |
+
│ ├── detector.py
|
| 223 |
+
│ └── state.py
|
| 224 |
+
│
|
| 225 |
+
├── ui/ # M8 + M13
|
| 226 |
+
│ ├── __init__.py
|
| 227 |
+
│ ├── app.py
|
| 228 |
+
│ ├── topology.py
|
| 229 |
+
│ ├── theme.py
|
| 230 |
+
│ ├── onboarding.py # M13
|
| 231 |
+
│ ├── tabs/
|
| 232 |
+
│ │ ├── __init__.py
|
| 233 |
+
│ │ ├── ask.py
|
| 234 |
+
│ │ ├── chat.py
|
| 235 |
+
│ │ ├── marketplace.py
|
| 236 |
+
│ │ ├── files.py
|
| 237 |
+
│ │ ├── emergency.py
|
| 238 |
+
│ │ └── settings.py
|
| 239 |
+
│ └── mobile/ # static assets
|
| 240 |
+
│ ├── index.html
|
| 241 |
+
│ ├── app.js
|
| 242 |
+
│ └── style.css
|
| 243 |
+
│
|
| 244 |
+
├── cli.py # M12
|
| 245 |
+
├── node.py # Orchestrator (composes everything)
|
| 246 |
+
└── doctor.py # (re-export of observability.doctor)
|
| 247 |
+
|
| 248 |
+
tests/
|
| 249 |
+
├── unit/ # per-module
|
| 250 |
+
│ ├── test_identity_keys.py
|
| 251 |
+
│ ├── test_identity_manifest.py
|
| 252 |
+
│ ├── test_events_log.py
|
| 253 |
+
│ ├── ...
|
| 254 |
+
└── integration/ # multi-node
|
| 255 |
+
├── test_three_node_mesh.py
|
| 256 |
+
├── test_emergency_mode.py
|
| 257 |
+
└── test_rejoin_sync.py
|
| 258 |
+
```
|
| 259 |
+
|
| 260 |
+
---
|
| 261 |
+
|
| 262 |
+
## 4. Canonical conventions
|
| 263 |
+
|
| 264 |
+
### 4.1 Type aliases (use these names everywhere)
|
| 265 |
+
|
| 266 |
+
```python
|
| 267 |
+
NodeID = str # "ed25519:XXXX-XXXX-XXXX-XXXX"
|
| 268 |
+
CommunityID = str # "ed25519:..."
|
| 269 |
+
CapabilityName = str # "llm.chat"
|
| 270 |
+
Version = tuple[int, int] # (1, 0)
|
| 271 |
+
Lamport = int
|
| 272 |
+
CID = str # "blake3:..."
|
| 273 |
+
EventID = str # ULID
|
| 274 |
+
TraceID = str # ULID
|
| 275 |
+
SchemaHash = str # "blake3:..."
|
| 276 |
+
WallClock = str # RFC 3339 UTC: "2026-05-26T08:14:22Z"
|
| 277 |
+
Signature = str # "ed25519:<base64-url-nopad>"
|
| 278 |
+
Topic = str # "marketplace.post.created"
|
| 279 |
+
ErrorCode = Literal[
|
| 280 |
+
"not_found", "capacity_exceeded", "schema_mismatch", "unauthorized",
|
| 281 |
+
"revoked", "internal_error", "not_implemented", "timeout", "partition",
|
| 282 |
+
"invalid_signature", "expired", "rate_limited", "bad_request",
|
| 283 |
+
]
|
| 284 |
+
TrustLevel = Literal["unknown", "member", "trusted", "anchor"]
|
| 285 |
+
Profile = Literal["anchor", "hearth", "spark", "bridge"]
|
| 286 |
+
Stability = Literal["experimental", "beta", "stable"]
|
| 287 |
+
```
|
| 288 |
+
|
| 289 |
+
These are defined in `hearthnet.types` and re-exported by every module that uses them. Never invent a synonym.
|
| 290 |
+
|
| 291 |
+
### 4.2 Naming
|
| 292 |
+
|
| 293 |
+
- Functions: `snake_case`, verb-first (`load_keys`, `verify_signature`, `route_request`)
|
| 294 |
+
- Classes: `PascalCase`, noun (`NodeManifest`, `CapabilityBus`, `RagService`)
|
| 295 |
+
- Constants: `SCREAMING_SNAKE` (`MANIFEST_TTL_SECONDS`)
|
| 296 |
+
- Module-private: leading underscore (`_compute_canonical_json`)
|
| 297 |
+
- Async functions: same naming as sync, no `async_` prefix
|
| 298 |
+
- Protocols / interfaces: `PascalCase` ending in capability noun (`LlmBackend`, `Service`)
|
| 299 |
+
|
| 300 |
+
### 4.3 Error handling
|
| 301 |
+
|
| 302 |
+
- All errors that cross a process boundary become an `HearthNetError` with an `ErrorCode`
|
| 303 |
+
- All errors that stay in-process are domain exceptions inheriting from `HearthNetError`
|
| 304 |
+
- Never raise `RuntimeError` or bare `Exception` in production code
|
| 305 |
+
- Logging exceptions: always with `exc_info=True` so traceback survives
|
| 306 |
+
|
| 307 |
+
### 4.4 Async vs sync
|
| 308 |
+
|
| 309 |
+
- I/O is async (asyncio)
|
| 310 |
+
- CPU-bound work (PDF parsing, embedding) runs in `asyncio.to_thread` or a dedicated process pool
|
| 311 |
+
- Public APIs are async unless explicitly noted (`load_keys` is sync, returns from disk)
|
| 312 |
+
|
| 313 |
+
### 4.5 Time
|
| 314 |
+
|
| 315 |
+
- All wall-clock timestamps in events: RFC 3339 UTC, e.g. `2026-05-26T08:14:22Z`
|
| 316 |
+
- All durations: integers in seconds, named `*_seconds` (never `*_secs`, `*_s`)
|
| 317 |
+
- Logical ordering: Lamport clocks; wall clock is advisory only
|
| 318 |
+
|
| 319 |
+
### 4.6 Sizes
|
| 320 |
+
|
| 321 |
+
- Bytes: integers, named `*_bytes`
|
| 322 |
+
- KB/MB/GB shown to users only at the UI layer, never in protocol/event payloads
|
| 323 |
+
|
| 324 |
+
---
|
| 325 |
+
|
| 326 |
+
## 5. Build order (hackathon-aligned)
|
| 327 |
+
|
| 328 |
+
Strictly follow this order. Each step is independently demoable.
|
| 329 |
+
|
| 330 |
+
| Step | Modules built | What you can demo |
|
| 331 |
+
|------|----------------------------------------|------------------------------|
|
| 332 |
+
| 1 | X04 (config), X03 (logging only) | Process boots, logs to file |
|
| 333 |
+
| 2 | M01 (identity, manifests) | `hearthnet init` works |
|
| 334 |
+
| 3 | X02 (events log + Lamport, no sync) | Marketplace events persist |
|
| 335 |
+
| 4 | X01 (transport, server only) | Two nodes can ping each other|
|
| 336 |
+
| 5 | M02 (discovery, mDNS only) | Two nodes find each other |
|
| 337 |
+
| 6 | M03 (bus with fake echo service) | A capability call round-trips|
|
| 338 |
+
| 7 | M04 (LLM, llama.cpp or LM Studio backend) | Real LLM call across nodes |
|
| 339 |
+
| 8 | M11 (embeddings) | Embed text via the bus |
|
| 340 |
+
| 9 | M05 (RAG) | RAG-grounded answer |
|
| 341 |
+
| 10 | M08 (UI shell + topology viz) | Visible mesh, visible routing|
|
| 342 |
+
| 11 | M09 (emergency detector) | Banner toggles on cable yank |
|
| 343 |
+
| 12 | M06 (marketplace) | Post visible across nodes |
|
| 344 |
+
| 13 | M10 (chat) | Cross-node DM |
|
| 345 |
+
| 14 | M07 (file/blobs) | CID-addressed file transfer |
|
| 346 |
+
| 15 | M12 (CLI), M13 (onboarding) | First-run UX polished |
|
| 347 |
+
| 16 | X02 sync, X03 metrics, X03 doctor | Production-ish polish |
|
| 348 |
+
|
| 349 |
+
---
|
| 350 |
+
|
| 351 |
+
## 6. Versioning of this spec set
|
| 352 |
+
|
| 353 |
+
Every spec file carries a header `Spec version: vX.Y` and a `Last touched: <date>`. Bump the minor on additive changes, the major on breaking changes. The contract document's version is the version everyone else complies with — module specs may lag by one minor.
|
| 354 |
+
|
| 355 |
+
The Python package version (`hearthnet.version.__version__`) and the contract version are independent. The contract version appears in the `version` field of every node manifest and is checked at handshake.
|
| 356 |
+
|
| 357 |
+
---
|
| 358 |
+
|
| 359 |
+
## 7. Out-of-band documents
|
| 360 |
+
|
| 361 |
+
Not in this spec set but referenced:
|
| 362 |
+
|
| 363 |
+
- **PRD v2** (`../HEARTHNET_PRD_v2.md`) — vision, scope, monetisation, phased roadmap
|
| 364 |
+
- **README** (in repo root, written later) — user-facing quickstart
|
| 365 |
+
- **THREAT_MODEL.md** (Phase 2) — formal security write-up
|
| 366 |
+
- **DEPLOYMENT.md** (Phase 2) — for the appliance and relay-tier operators
|
docs/CAPABILITY_CONTRACT.md
ADDED
|
@@ -0,0 +1,1332 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# HearthNet Capability Contract
|
| 2 |
+
|
| 3 |
+
**Spec version:** v1.0
|
| 4 |
+
**Last touched:** 2026-06-07
|
| 5 |
+
**Scope:** wire-level protocol, capability schemas, event schemas, signing rules.
|
| 6 |
+
|
| 7 |
+
This document is the source of truth. Any conflict with a module spec is resolved in favour of this document.
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## 1. Conventions
|
| 12 |
+
|
| 13 |
+
### 1.1 Encodings
|
| 14 |
+
|
| 15 |
+
- **Wire format**: JSON, UTF-8, no BOM. Numbers fit IEEE 754 double; integers fit in 53 bits. Where 64-bit precision is needed (rare), use strings.
|
| 16 |
+
- **Binary content**: Base64-URL without padding, prefixed by encoding tag where ambiguous (`ed25519:`, `blake3:`).
|
| 17 |
+
- **Timestamps**: RFC 3339 UTC with `Z`, e.g. `2026-05-26T08:14:22Z`. No timezone offsets, no fractional seconds beyond milliseconds (`...22.281Z` is allowed for tracing only).
|
| 18 |
+
- **Durations**: integer seconds, suffix `_seconds` in field names.
|
| 19 |
+
- **Sizes**: integer bytes, suffix `_bytes`. UI may convert to KB/MB; wire never does.
|
| 20 |
+
|
| 21 |
+
### 1.2 Canonical JSON
|
| 22 |
+
|
| 23 |
+
For any payload that is signed or hashed, the canonical form is:
|
| 24 |
+
|
| 25 |
+
- Keys sorted lexicographically at every level
|
| 26 |
+
- No whitespace between tokens
|
| 27 |
+
- Numbers without trailing zeros: `1.0` → `1`, `1.10` → `1.1`
|
| 28 |
+
- Strings UTF-8, non-ASCII characters not escaped
|
| 29 |
+
- `null` allowed only where the schema declares it
|
| 30 |
+
|
| 31 |
+
A reference implementation is `hearthnet.identity.keys.canonical_json(obj) -> bytes`. Use it always.
|
| 32 |
+
|
| 33 |
+
### 1.3 Signing primitive
|
| 34 |
+
|
| 35 |
+
Ed25519 over the canonical-JSON byte string of the payload excluding the `signature` field itself. The signature field is added back after signing. To verify, strip `signature`, re-canonicalise, verify.
|
| 36 |
+
|
| 37 |
+
### 1.4 Hashing primitive
|
| 38 |
+
|
| 39 |
+
BLAKE3. Output: 32 bytes, presented as lowercase hex with the prefix `blake3:`. Where short forms are needed (display), use the first 16 hex chars: `blake3:abc123...`. The full hex is always used in protocol fields.
|
| 40 |
+
|
| 41 |
+
### 1.5 Identifier forms
|
| 42 |
+
|
| 43 |
+
See [GLOSSARY.md](GLOSSARY.md). Identifiers in protocol payloads always use the full form. Display forms are UI-only.
|
| 44 |
+
|
| 45 |
+
---
|
| 46 |
+
|
| 47 |
+
## 2. Versioning
|
| 48 |
+
|
| 49 |
+
### 2.1 Capability version
|
| 50 |
+
|
| 51 |
+
A capability declares `version: "X.Y"` where X is major, Y is minor.
|
| 52 |
+
|
| 53 |
+
- **Compatibility**: a request asking for `name@>=A.B` is satisfied by an offered `name@X.Y` iff `X == A` and `Y >= B`.
|
| 54 |
+
- **Major bumps**: breaking. Old callers receive `schema_mismatch`.
|
| 55 |
+
- **Minor bumps**: additive only. Old callers continue to work. New fields are optional with documented defaults.
|
| 56 |
+
- The `schema_hash` (BLAKE3 of the request + response schema) is recomputed on every bump. Two nodes with the same `schema_hash` for a capability speak identically.
|
| 57 |
+
|
| 58 |
+
### 2.2 Contract version
|
| 59 |
+
|
| 60 |
+
This document is versioned independently of capabilities. Node manifests carry `contract_version: "1.0"` so peers can refuse to talk to incompatible contract revisions.
|
| 61 |
+
|
| 62 |
+
### 2.3 Event schema version
|
| 63 |
+
|
| 64 |
+
Each event carries a `schema_version` field. Old events are kept verbatim; readers translate via a versioned schema registry. Never rewrite history.
|
| 65 |
+
|
| 66 |
+
---
|
| 67 |
+
|
| 68 |
+
## 3. Capability namespace
|
| 69 |
+
|
| 70 |
+
### 3.1 Prefix allocation
|
| 71 |
+
|
| 72 |
+
| Prefix | Owner | Stability of prefix | Defined in |
|
| 73 |
+
|--------|-------|---------------------|------------|
|
| 74 |
+
| `llm.*` | LLM service | stable | M04 |
|
| 75 |
+
| `embed.*` | Embedding service | stable | M11 |
|
| 76 |
+
| `rag.*` | RAG service | stable | M05 |
|
| 77 |
+
| `file.*` | File service | stable | M07 |
|
| 78 |
+
| `market.*` | Marketplace | stable | M06 |
|
| 79 |
+
| `chat.*` | Chat | stable | M10 |
|
| 80 |
+
| `community.*` | Trust ops | stable | M01 + X02 |
|
| 81 |
+
| `federation.*` | Cross-community | beta | Phase 2 |
|
| 82 |
+
| `ocr.*` | OCR (Phase 2) | reserved | — |
|
| 83 |
+
| `tts.*` `stt.*` | Speech (Phase 2) | reserved | — |
|
| 84 |
+
| `trans.*` | Translation (Phase 2) | reserved | — |
|
| 85 |
+
| `img.*` | Images (Phase 2) | reserved | — |
|
| 86 |
+
| `experimental.*` | Anything not promoted | unstable | any |
|
| 87 |
+
|
| 88 |
+
Reserved prefixes may not be used. Capabilities outside the reserved set must start with `experimental.`.
|
| 89 |
+
|
| 90 |
+
### 3.2 Complete capability list (this release)
|
| 91 |
+
|
| 92 |
+
| Name | Stability | Stream? | Trust required | Section |
|
| 93 |
+
|------|-----------|---------|----------------|---------|
|
| 94 |
+
| `llm.chat@1.0` | stable | yes | member | §4.1 |
|
| 95 |
+
| `llm.complete@1.0` | stable | yes | member | §4.2 |
|
| 96 |
+
| `embed.text@1.0` | stable | no | member | §4.3 |
|
| 97 |
+
| `rag.query@1.0` | stable | no | member | §4.4 |
|
| 98 |
+
| `rag.ingest@1.0` | stable | no | trusted | §4.5 |
|
| 99 |
+
| `rag.list_corpora@1.0` | stable | no | member | §4.6 |
|
| 100 |
+
| `file.read@1.0` | stable | yes (chunks) | member | §4.7 |
|
| 101 |
+
| `file.list@1.0` | stable | no | member | §4.8 |
|
| 102 |
+
| `file.advertise@1.0` | stable | no | member | §4.9 |
|
| 103 |
+
| `file.put@1.0` | stable | yes (chunks) | trusted | §4.10 |
|
| 104 |
+
| `market.list@1.0` | stable | no | member | §4.11 |
|
| 105 |
+
| `market.post@1.0` | stable | no | member | §4.12 |
|
| 106 |
+
| `market.expire@1.0` | stable | no | member (own only) | §4.13 |
|
| 107 |
+
| `market.search@1.0` | stable | no | member | §4.14 |
|
| 108 |
+
| `chat.send@1.0` | stable | no | member | §4.15 |
|
| 109 |
+
| `chat.history@1.0` | stable | no | member (self only) | §4.16 |
|
| 110 |
+
| `community.invite@1.0` | stable | no | member with invite right | §4.17 |
|
| 111 |
+
| `community.revoke@1.0` | stable | no | 3 of trusted | §4.18 |
|
| 112 |
+
|
| 113 |
+
`federation.*`, `ocr.*`, `tts.*`, `stt.*`, `trans.*`, `img.*` are out of scope for this release; placeholders only.
|
| 114 |
+
|
| 115 |
+
---
|
| 116 |
+
|
| 117 |
+
## 4. Per-capability specifications
|
| 118 |
+
|
| 119 |
+
For each capability the spec gives:
|
| 120 |
+
|
| 121 |
+
- **Purpose**
|
| 122 |
+
- **Trust required**
|
| 123 |
+
- **Idempotency**
|
| 124 |
+
- **Request schema** (JSON Schema-ish; required fields marked)
|
| 125 |
+
- **Response schema** (or stream frame schema)
|
| 126 |
+
- **Errors** (codes that this capability may return beyond the universal set)
|
| 127 |
+
- **Example request and response**
|
| 128 |
+
|
| 129 |
+
The universal error codes apply to every capability: `bad_request`, `unauthorized`, `revoked`, `capacity_exceeded`, `internal_error`, `timeout`, `partition`, `invalid_signature`, `expired`, `rate_limited`.
|
| 130 |
+
|
| 131 |
+
### 4.1 `llm.chat@1.0`
|
| 132 |
+
|
| 133 |
+
- **Purpose**: Multi-turn chat completion. Server-streams tokens.
|
| 134 |
+
- **Trust**: member
|
| 135 |
+
- **Idempotency**: no (token sampling is non-deterministic)
|
| 136 |
+
- **Stream**: yes (SSE)
|
| 137 |
+
|
| 138 |
+
#### Request
|
| 139 |
+
|
| 140 |
+
```json
|
| 141 |
+
{
|
| 142 |
+
"params": {
|
| 143 |
+
"model": "qwen2.5-7b-instruct", // required, must match an offered model
|
| 144 |
+
"ctx": 8192 // optional, default = declared max
|
| 145 |
+
},
|
| 146 |
+
"input": {
|
| 147 |
+
"messages": [ // required, ≥ 1
|
| 148 |
+
{"role": "system", "content": "..."}, // optional
|
| 149 |
+
{"role": "user", "content": "..."}, // required at least once
|
| 150 |
+
{"role": "assistant", "content": "..."}
|
| 151 |
+
],
|
| 152 |
+
"max_tokens": 512, // optional, default 1024
|
| 153 |
+
"temperature": 0.7, // optional, default 0.7
|
| 154 |
+
"top_p": 0.95, // optional, default 0.95
|
| 155 |
+
"stop": ["</s>"], // optional
|
| 156 |
+
"seed": 42, // optional; if set, server SHOULD make deterministic
|
| 157 |
+
"tools": [], // optional, OpenAI-compatible tool defs (Phase 2)
|
| 158 |
+
"tool_choice": "auto", // optional
|
| 159 |
+
"stream": true // optional, default true
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
#### Response — non-stream (only if `stream:false`)
|
| 165 |
+
|
| 166 |
+
```json
|
| 167 |
+
{
|
| 168 |
+
"output": {
|
| 169 |
+
"message": {"role": "assistant", "content": "..."},
|
| 170 |
+
"tool_calls": [] // optional
|
| 171 |
+
},
|
| 172 |
+
"meta": {
|
| 173 |
+
"model": "qwen2.5-7b-instruct",
|
| 174 |
+
"tokens_in": 42,
|
| 175 |
+
"tokens_out": 178,
|
| 176 |
+
"stop_reason": "end", // "end" | "max_tokens" | "stop_sequence" | "cancelled"
|
| 177 |
+
"ms": 1834
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
#### Stream frames
|
| 183 |
+
|
| 184 |
+
```
|
| 185 |
+
event: token
|
| 186 |
+
data: {"text":"Sie ", "logprob": -0.21}
|
| 187 |
+
|
| 188 |
+
event: tool_call_delta (only if tools used; Phase 2)
|
| 189 |
+
data: {"id":"...","name":"search","arguments_delta":"{\"q\":\"..."}
|
| 190 |
+
|
| 191 |
+
event: done
|
| 192 |
+
data: {"tokens_out": 178, "stop_reason": "end", "ms": 1834}
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
A `done` frame is always sent. Client closing the connection mid-stream cancels generation within 200ms (server SHOULD abort).
|
| 196 |
+
|
| 197 |
+
#### Errors
|
| 198 |
+
|
| 199 |
+
Beyond universal:
|
| 200 |
+
- `not_implemented` — server registered the capability but backend is missing the model
|
| 201 |
+
- `bad_request` — malformed messages, empty messages, role sequence violation
|
| 202 |
+
|
| 203 |
+
### 4.2 `llm.complete@1.0`
|
| 204 |
+
|
| 205 |
+
- **Purpose**: Single-shot completion (no chat structure). Used by RAG internally and by classical tooling.
|
| 206 |
+
- **Trust**: member
|
| 207 |
+
- **Stream**: yes
|
| 208 |
+
|
| 209 |
+
#### Request
|
| 210 |
+
|
| 211 |
+
```json
|
| 212 |
+
{
|
| 213 |
+
"params": {"model": "qwen2.5-7b-instruct"},
|
| 214 |
+
"input": {
|
| 215 |
+
"prompt": "...", // required
|
| 216 |
+
"max_tokens": 256,
|
| 217 |
+
"temperature": 0.7,
|
| 218 |
+
"top_p": 0.95,
|
| 219 |
+
"stop": ["\n\n"],
|
| 220 |
+
"seed": null,
|
| 221 |
+
"stream": true
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
```
|
| 225 |
+
|
| 226 |
+
#### Response (non-stream)
|
| 227 |
+
|
| 228 |
+
```json
|
| 229 |
+
{
|
| 230 |
+
"output": {"text": "..."},
|
| 231 |
+
"meta": {"model": "...", "tokens_in": 12, "tokens_out": 80, "stop_reason": "end", "ms": 312}
|
| 232 |
+
}
|
| 233 |
+
```
|
| 234 |
+
|
| 235 |
+
#### Stream frames
|
| 236 |
+
|
| 237 |
+
Same as `llm.chat` but only `token` and `done`. No `tool_call_delta`.
|
| 238 |
+
|
| 239 |
+
### 4.3 `embed.text@1.0`
|
| 240 |
+
|
| 241 |
+
- **Purpose**: Embed one or many strings into vectors.
|
| 242 |
+
- **Trust**: member
|
| 243 |
+
- **Idempotency**: yes (assuming deterministic backend)
|
| 244 |
+
|
| 245 |
+
#### Request
|
| 246 |
+
|
| 247 |
+
```json
|
| 248 |
+
{
|
| 249 |
+
"params": {"model": "bge-small-en-v1.5"},
|
| 250 |
+
"input": {
|
| 251 |
+
"texts": ["...", "...", "..."], // required, 1..256
|
| 252 |
+
"normalize": true // optional, default true
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
```
|
| 256 |
+
|
| 257 |
+
#### Response
|
| 258 |
+
|
| 259 |
+
```json
|
| 260 |
+
{
|
| 261 |
+
"output": {
|
| 262 |
+
"embeddings": [[0.012, -0.043, ...], [...], [...]],
|
| 263 |
+
"dim": 384
|
| 264 |
+
},
|
| 265 |
+
"meta": {"model": "bge-small-en-v1.5", "ms": 38}
|
| 266 |
+
}
|
| 267 |
+
```
|
| 268 |
+
|
| 269 |
+
#### Errors
|
| 270 |
+
|
| 271 |
+
- `bad_request` — > 256 texts, or any text > 8192 chars
|
| 272 |
+
|
| 273 |
+
### 4.4 `rag.query@1.0`
|
| 274 |
+
|
| 275 |
+
- **Purpose**: Retrieve top-K relevant chunks from a named corpus.
|
| 276 |
+
- **Trust**: member
|
| 277 |
+
- **Idempotency**: yes
|
| 278 |
+
|
| 279 |
+
#### Request
|
| 280 |
+
|
| 281 |
+
```json
|
| 282 |
+
{
|
| 283 |
+
"params": {"corpus": "niederrhein-emergency"},
|
| 284 |
+
"input": {
|
| 285 |
+
"query": "Wie reinige ich Regenwasser?",
|
| 286 |
+
"k": 5, // optional, default 5, max 20
|
| 287 |
+
"filter": { // optional metadata filter
|
| 288 |
+
"language": "de",
|
| 289 |
+
"min_year": 2000
|
| 290 |
+
},
|
| 291 |
+
"include_text": true // optional, default true
|
| 292 |
+
}
|
| 293 |
+
}
|
| 294 |
+
```
|
| 295 |
+
|
| 296 |
+
#### Response
|
| 297 |
+
|
| 298 |
+
```json
|
| 299 |
+
{
|
| 300 |
+
"output": {
|
| 301 |
+
"chunks": [
|
| 302 |
+
{
|
| 303 |
+
"rank": 1,
|
| 304 |
+
"score": 0.84,
|
| 305 |
+
"text": "Regenwasser kann durch Filtration ...",
|
| 306 |
+
"metadata": {
|
| 307 |
+
"doc_cid": "blake3:...",
|
| 308 |
+
"doc_title": "Notfall-Trinkwasser",
|
| 309 |
+
"page": 12,
|
| 310 |
+
"chunk_id": "ch_001"
|
| 311 |
+
}
|
| 312 |
+
}
|
| 313 |
+
]
|
| 314 |
+
},
|
| 315 |
+
"meta": {"corpus": "niederrhein-emergency", "ms": 24, "embedding_model": "bge-small-en-v1.5"}
|
| 316 |
+
}
|
| 317 |
+
```
|
| 318 |
+
|
| 319 |
+
#### Errors
|
| 320 |
+
|
| 321 |
+
- `not_found` — corpus does not exist
|
| 322 |
+
- `bad_request` — k > 20
|
| 323 |
+
|
| 324 |
+
### 4.5 `rag.ingest@1.0`
|
| 325 |
+
|
| 326 |
+
- **Purpose**: Add a document to a corpus.
|
| 327 |
+
- **Trust**: trusted (corpus pollution is a real risk)
|
| 328 |
+
- **Idempotency**: by content hash (`doc_cid`)
|
| 329 |
+
|
| 330 |
+
#### Request
|
| 331 |
+
|
| 332 |
+
```json
|
| 333 |
+
{
|
| 334 |
+
"params": {"corpus": "niederrhein-emergency"},
|
| 335 |
+
"input": {
|
| 336 |
+
"doc_cid": "blake3:...", // required; document must already be in blob store
|
| 337 |
+
"title": "Notfall-Trinkwasser",
|
| 338 |
+
"language": "de",
|
| 339 |
+
"metadata": {"author": "...", "year": 2024}
|
| 340 |
+
}
|
| 341 |
+
}
|
| 342 |
+
```
|
| 343 |
+
|
| 344 |
+
#### Response
|
| 345 |
+
|
| 346 |
+
```json
|
| 347 |
+
{
|
| 348 |
+
"output": {
|
| 349 |
+
"doc_cid": "blake3:...",
|
| 350 |
+
"chunks_indexed": 87,
|
| 351 |
+
"tokens_indexed": 18342
|
| 352 |
+
},
|
| 353 |
+
"meta": {"corpus": "niederrhein-emergency", "ms": 4210, "ingest_event_id": "01HXR..."}
|
| 354 |
+
}
|
| 355 |
+
```
|
| 356 |
+
|
| 357 |
+
The ingest also publishes a `rag.document.ingested` event (§7).
|
| 358 |
+
|
| 359 |
+
#### Errors
|
| 360 |
+
|
| 361 |
+
- `not_found` — `doc_cid` not resolvable to a blob
|
| 362 |
+
- `bad_request` — unsupported media type
|
| 363 |
+
|
| 364 |
+
### 4.6 `rag.list_corpora@1.0`
|
| 365 |
+
|
| 366 |
+
#### Request
|
| 367 |
+
|
| 368 |
+
```json
|
| 369 |
+
{"params": {}, "input": {}}
|
| 370 |
+
```
|
| 371 |
+
|
| 372 |
+
#### Response
|
| 373 |
+
|
| 374 |
+
```json
|
| 375 |
+
{
|
| 376 |
+
"output": {
|
| 377 |
+
"corpora": [
|
| 378 |
+
{"name": "niederrhein-emergency", "docs": 6, "chunks": 412, "size_bytes": 18243842, "language_majority": "de"}
|
| 379 |
+
]
|
| 380 |
+
},
|
| 381 |
+
"meta": {"ms": 2}
|
| 382 |
+
}
|
| 383 |
+
```
|
| 384 |
+
|
| 385 |
+
### 4.7 `file.read@1.0`
|
| 386 |
+
|
| 387 |
+
- **Purpose**: Fetch a single chunk by CID, or a whole blob via streaming chunks.
|
| 388 |
+
- **Trust**: member
|
| 389 |
+
- **Stream**: yes (chunk frames)
|
| 390 |
+
|
| 391 |
+
#### Request
|
| 392 |
+
|
| 393 |
+
```json
|
| 394 |
+
{
|
| 395 |
+
"params": {},
|
| 396 |
+
"input": {"cid": "blake3:..."} // either a chunk CID or a blob manifest CID
|
| 397 |
+
}
|
| 398 |
+
```
|
| 399 |
+
|
| 400 |
+
#### Response (single chunk, non-stream)
|
| 401 |
+
|
| 402 |
+
If `cid` resolves to a chunk:
|
| 403 |
+
|
| 404 |
+
```json
|
| 405 |
+
{
|
| 406 |
+
"output": {
|
| 407 |
+
"cid": "blake3:...",
|
| 408 |
+
"size_bytes": 262144,
|
| 409 |
+
"data_b64": "..." // chunk bytes, base64
|
| 410 |
+
},
|
| 411 |
+
"meta": {"ms": 5}
|
| 412 |
+
}
|
| 413 |
+
```
|
| 414 |
+
|
| 415 |
+
#### Stream frames (manifest CID, multi-chunk)
|
| 416 |
+
|
| 417 |
+
If `cid` resolves to a blob manifest:
|
| 418 |
+
|
| 419 |
+
```
|
| 420 |
+
event: manifest
|
| 421 |
+
data: {"cid":"blake3:...","size_bytes":4824711,"chunk_size_bytes":262144,"chunks":[{"i":0,"cid":"blake3:..."}, ...]}
|
| 422 |
+
|
| 423 |
+
event: chunk
|
| 424 |
+
data: {"i":0,"cid":"blake3:...","size_bytes":262144,"data_b64":"..."}
|
| 425 |
+
|
| 426 |
+
event: chunk
|
| 427 |
+
data: {"i":1,"cid":"blake3:...","size_bytes":262144,"data_b64":"..."}
|
| 428 |
+
|
| 429 |
+
event: done
|
| 430 |
+
data: {"chunks":19,"ms":4218}
|
| 431 |
+
```
|
| 432 |
+
|
| 433 |
+
Clients verify each chunk's BLAKE3 before storing.
|
| 434 |
+
|
| 435 |
+
#### Errors
|
| 436 |
+
|
| 437 |
+
- `not_found` — server does not have this CID
|
| 438 |
+
|
| 439 |
+
### 4.8 `file.list@1.0`
|
| 440 |
+
|
| 441 |
+
#### Request
|
| 442 |
+
|
| 443 |
+
```json
|
| 444 |
+
{"params": {}, "input": {"prefix": "blake3:abc"}} // prefix optional
|
| 445 |
+
```
|
| 446 |
+
|
| 447 |
+
#### Response
|
| 448 |
+
|
| 449 |
+
```json
|
| 450 |
+
{"output": {"cids": ["blake3:abc...", "blake3:abd..."]}, "meta": {"ms": 3}}
|
| 451 |
+
```
|
| 452 |
+
|
| 453 |
+
### 4.9 `file.advertise@1.0`
|
| 454 |
+
|
| 455 |
+
- **Purpose**: Used during gossip sync; one node tells another it now holds a CID.
|
| 456 |
+
- **Trust**: member
|
| 457 |
+
- **Idempotency**: yes
|
| 458 |
+
|
| 459 |
+
#### Request
|
| 460 |
+
|
| 461 |
+
```json
|
| 462 |
+
{"params": {}, "input": {"cids": ["blake3:..."]}}
|
| 463 |
+
```
|
| 464 |
+
|
| 465 |
+
#### Response
|
| 466 |
+
|
| 467 |
+
```json
|
| 468 |
+
{"output": {"recorded": 1}, "meta": {"ms": 1}}
|
| 469 |
+
```
|
| 470 |
+
|
| 471 |
+
### 4.10 `file.put@1.0`
|
| 472 |
+
|
| 473 |
+
- **Purpose**: Offer a blob to a remote node (typically used to share an emergency PDF widely).
|
| 474 |
+
- **Trust**: trusted
|
| 475 |
+
- **Stream**: yes (client-stream of chunks)
|
| 476 |
+
|
| 477 |
+
#### Request — initial frame
|
| 478 |
+
|
| 479 |
+
```json
|
| 480 |
+
{"params": {}, "input": {"manifest": {"cid":"blake3:...", "size_bytes":..., "chunks":[...]}}}
|
| 481 |
+
```
|
| 482 |
+
|
| 483 |
+
Server responds with a `ready` event including a list of chunks it does not yet have. Client then streams those chunks. On completion, server replies with `done`.
|
| 484 |
+
|
| 485 |
+
```
|
| 486 |
+
event: ready
|
| 487 |
+
data: {"needed":[0,1,2,3, ...]}
|
| 488 |
+
|
| 489 |
+
(client sends:)
|
| 490 |
+
event: chunk
|
| 491 |
+
data: {"i":0,"cid":"blake3:...","data_b64":"..."}
|
| 492 |
+
|
| 493 |
+
(server:)
|
| 494 |
+
event: done
|
| 495 |
+
data: {"received":4,"ms":1832}
|
| 496 |
+
```
|
| 497 |
+
|
| 498 |
+
#### Errors
|
| 499 |
+
|
| 500 |
+
- `unauthorized` — caller not trusted
|
| 501 |
+
- `capacity_exceeded` — disk full or GC threshold reached
|
| 502 |
+
|
| 503 |
+
### 4.11 `market.list@1.0`
|
| 504 |
+
|
| 505 |
+
- **Purpose**: List current (non-expired) marketplace posts in this community.
|
| 506 |
+
- **Trust**: member
|
| 507 |
+
- **Idempotency**: yes (snapshot read)
|
| 508 |
+
|
| 509 |
+
#### Request
|
| 510 |
+
|
| 511 |
+
```json
|
| 512 |
+
{
|
| 513 |
+
"params": {},
|
| 514 |
+
"input": {
|
| 515 |
+
"category": "offer", // optional: "offer" | "request" | "info" | "emergency"
|
| 516 |
+
"tags": ["wasser"], // optional
|
| 517 |
+
"since_lamport": 4000, // optional, for delta sync
|
| 518 |
+
"limit": 50 // optional, default 50, max 500
|
| 519 |
+
}
|
| 520 |
+
}
|
| 521 |
+
```
|
| 522 |
+
|
| 523 |
+
#### Response
|
| 524 |
+
|
| 525 |
+
```json
|
| 526 |
+
{
|
| 527 |
+
"output": {
|
| 528 |
+
"posts": [
|
| 529 |
+
{
|
| 530 |
+
"event_id": "01HXR...",
|
| 531 |
+
"lamport": 4218,
|
| 532 |
+
"author": "ed25519:...",
|
| 533 |
+
"category": "request",
|
| 534 |
+
"title": "Suche Wasserkanister, 20L",
|
| 535 |
+
"body": "...",
|
| 536 |
+
"location": {"lat": 51.5, "lng": 6.2, "label": "Issum"},
|
| 537 |
+
"tags": ["wasser","notfall"],
|
| 538 |
+
"created_at": "2026-05-26T08:14:22Z",
|
| 539 |
+
"expires_at": "2026-05-27T08:14:22Z"
|
| 540 |
+
}
|
| 541 |
+
],
|
| 542 |
+
"max_lamport": 4231
|
| 543 |
+
},
|
| 544 |
+
"meta": {"ms": 8}
|
| 545 |
+
}
|
| 546 |
+
```
|
| 547 |
+
|
| 548 |
+
### 4.12 `market.post@1.0`
|
| 549 |
+
|
| 550 |
+
- **Purpose**: Create a marketplace post.
|
| 551 |
+
- **Trust**: member
|
| 552 |
+
- **Idempotency**: yes, by `client_id` (caller-generated UUID)
|
| 553 |
+
|
| 554 |
+
#### Request
|
| 555 |
+
|
| 556 |
+
```json
|
| 557 |
+
{
|
| 558 |
+
"params": {},
|
| 559 |
+
"input": {
|
| 560 |
+
"client_id": "01HXR...", // required, used for dedup
|
| 561 |
+
"category": "request",
|
| 562 |
+
"title": "Suche Wasserkanister, 20L",
|
| 563 |
+
"body": "Brauche bis morgen ...",
|
| 564 |
+
"location": {"lat": 51.5, "lng": 6.2, "label": "Issum"},
|
| 565 |
+
"tags": ["wasser","notfall"],
|
| 566 |
+
"ttl_seconds": 86400 // optional, default 7 days, max 30 days
|
| 567 |
+
}
|
| 568 |
+
}
|
| 569 |
+
```
|
| 570 |
+
|
| 571 |
+
#### Response
|
| 572 |
+
|
| 573 |
+
```json
|
| 574 |
+
{
|
| 575 |
+
"output": {"event_id": "01HXR...", "lamport": 4218},
|
| 576 |
+
"meta": {"ms": 6}
|
| 577 |
+
}
|
| 578 |
+
```
|
| 579 |
+
|
| 580 |
+
The post emits a `market.post.created` event (§7).
|
| 581 |
+
|
| 582 |
+
### 4.13 `market.expire@1.0`
|
| 583 |
+
|
| 584 |
+
#### Request
|
| 585 |
+
|
| 586 |
+
```json
|
| 587 |
+
{
|
| 588 |
+
"params": {},
|
| 589 |
+
"input": {
|
| 590 |
+
"client_id": "01HXR...", // dedup
|
| 591 |
+
"event_id": "01HXR...", // the original post's id
|
| 592 |
+
"reason": "fulfilled" // "fulfilled" | "withdrawn" | "user_request" | "stale"
|
| 593 |
+
}
|
| 594 |
+
}
|
| 595 |
+
```
|
| 596 |
+
|
| 597 |
+
#### Response
|
| 598 |
+
|
| 599 |
+
```json
|
| 600 |
+
{"output": {"event_id": "01HXS...", "lamport": 4252}, "meta": {"ms": 3}}
|
| 601 |
+
```
|
| 602 |
+
|
| 603 |
+
#### Errors
|
| 604 |
+
|
| 605 |
+
- `unauthorized` — caller is not the original author and not a trusted moderator
|
| 606 |
+
- `not_found` — original post not found
|
| 607 |
+
|
| 608 |
+
### 4.14 `market.search@1.0`
|
| 609 |
+
|
| 610 |
+
- **Purpose**: Semantic search across posts using embeddings.
|
| 611 |
+
- **Trust**: member
|
| 612 |
+
|
| 613 |
+
#### Request
|
| 614 |
+
|
| 615 |
+
```json
|
| 616 |
+
{
|
| 617 |
+
"params": {},
|
| 618 |
+
"input": {
|
| 619 |
+
"query": "wasser notfall kanister",
|
| 620 |
+
"k": 10
|
| 621 |
+
}
|
| 622 |
+
}
|
| 623 |
+
```
|
| 624 |
+
|
| 625 |
+
#### Response
|
| 626 |
+
|
| 627 |
+
Same shape as `market.list` but ordered by semantic similarity. Each post has an additional `score` field.
|
| 628 |
+
|
| 629 |
+
### 4.15 `chat.send@1.0`
|
| 630 |
+
|
| 631 |
+
- **Purpose**: Send a direct message to one recipient.
|
| 632 |
+
- **Trust**: member
|
| 633 |
+
- **Idempotency**: yes, by `client_id`
|
| 634 |
+
|
| 635 |
+
#### Request
|
| 636 |
+
|
| 637 |
+
```json
|
| 638 |
+
{
|
| 639 |
+
"params": {},
|
| 640 |
+
"input": {
|
| 641 |
+
"client_id": "01HXR...",
|
| 642 |
+
"recipient": "ed25519:...", // recipient NodeID (full form)
|
| 643 |
+
"body": "Hi, hast du heute Strom?",
|
| 644 |
+
"attachments": [ // optional
|
| 645 |
+
{"cid": "blake3:...", "name": "schaltplan.pdf"}
|
| 646 |
+
]
|
| 647 |
+
}
|
| 648 |
+
}
|
| 649 |
+
```
|
| 650 |
+
|
| 651 |
+
#### Response
|
| 652 |
+
|
| 653 |
+
```json
|
| 654 |
+
{"output": {"event_id": "01HXR...", "lamport": 4301, "delivered": "direct"}, "meta": {"ms": 4}}
|
| 655 |
+
```
|
| 656 |
+
|
| 657 |
+
`delivered` is `"direct"` if recipient is online, `"forwarded"` if held by store-and-forward, `"queued"` if no anchor is willing.
|
| 658 |
+
|
| 659 |
+
### 4.16 `chat.history@1.0`
|
| 660 |
+
|
| 661 |
+
- **Purpose**: Retrieve local chat history with one peer.
|
| 662 |
+
- **Trust**: self only — node returns only its own conversations
|
| 663 |
+
- **Idempotency**: yes
|
| 664 |
+
|
| 665 |
+
#### Request
|
| 666 |
+
|
| 667 |
+
```json
|
| 668 |
+
{
|
| 669 |
+
"params": {},
|
| 670 |
+
"input": {
|
| 671 |
+
"peer": "ed25519:...", // optional; if omitted, return all peers
|
| 672 |
+
"since_lamport": 4000,
|
| 673 |
+
"limit": 200
|
| 674 |
+
}
|
| 675 |
+
}
|
| 676 |
+
```
|
| 677 |
+
|
| 678 |
+
#### Response
|
| 679 |
+
|
| 680 |
+
```json
|
| 681 |
+
{
|
| 682 |
+
"output": {
|
| 683 |
+
"messages": [
|
| 684 |
+
{
|
| 685 |
+
"event_id": "01HXR...",
|
| 686 |
+
"lamport": 4301,
|
| 687 |
+
"from": "ed25519:...",
|
| 688 |
+
"to": "ed25519:...",
|
| 689 |
+
"body": "...",
|
| 690 |
+
"attachments": [],
|
| 691 |
+
"created_at": "2026-05-26T08:14:22Z",
|
| 692 |
+
"delivered_at": "2026-05-26T08:14:23Z",
|
| 693 |
+
"read_at": "2026-05-26T08:15:00Z"
|
| 694 |
+
}
|
| 695 |
+
]
|
| 696 |
+
},
|
| 697 |
+
"meta": {"ms": 5}
|
| 698 |
+
}
|
| 699 |
+
```
|
| 700 |
+
|
| 701 |
+
### 4.17 `community.invite@1.0`
|
| 702 |
+
|
| 703 |
+
- **Purpose**: Invite a new device into the community.
|
| 704 |
+
- **Trust**: member with the `can_invite` policy bit
|
| 705 |
+
- **Idempotency**: yes, by `invitee_node_id`
|
| 706 |
+
|
| 707 |
+
#### Request
|
| 708 |
+
|
| 709 |
+
```json
|
| 710 |
+
{
|
| 711 |
+
"params": {},
|
| 712 |
+
"input": {
|
| 713 |
+
"invitee_node_id": "ed25519:...", // full pubkey
|
| 714 |
+
"display_name": "Hannes' Tablet",
|
| 715 |
+
"initial_level": "member", // "member" or "trusted"
|
| 716 |
+
"expires_at": "2026-05-27T00:00:00Z"
|
| 717 |
+
}
|
| 718 |
+
}
|
| 719 |
+
```
|
| 720 |
+
|
| 721 |
+
#### Response
|
| 722 |
+
|
| 723 |
+
```json
|
| 724 |
+
{
|
| 725 |
+
"output": {
|
| 726 |
+
"invite_blob": "ed25519:<base64-url-nopad>" // a signed, scannable invite, encoded for QR
|
| 727 |
+
},
|
| 728 |
+
"meta": {"event_id": "01HXR...", "lamport": 4310, "ms": 3}
|
| 729 |
+
}
|
| 730 |
+
```
|
| 731 |
+
|
| 732 |
+
The invite produces a `community.member.invited` event. The invitee redeems it locally; redemption creates a `community.member.joined` event.
|
| 733 |
+
|
| 734 |
+
### 4.18 `community.revoke@1.0`
|
| 735 |
+
|
| 736 |
+
- **Purpose**: Remove a member. Requires 3 trusted-member signatures over the same revocation payload.
|
| 737 |
+
- **Trust**: see signature requirements
|
| 738 |
+
|
| 739 |
+
#### Request
|
| 740 |
+
|
| 741 |
+
```json
|
| 742 |
+
{
|
| 743 |
+
"params": {},
|
| 744 |
+
"input": {
|
| 745 |
+
"client_id": "01HXR...",
|
| 746 |
+
"target_node_id": "ed25519:...",
|
| 747 |
+
"reason": "compromised|inactive|policy_violation|other",
|
| 748 |
+
"co_signers": [
|
| 749 |
+
{"node_id": "ed25519:...", "signature": "ed25519:..."},
|
| 750 |
+
{"node_id": "ed25519:...", "signature": "ed25519:..."},
|
| 751 |
+
{"node_id": "ed25519:...", "signature": "ed25519:..."}
|
| 752 |
+
]
|
| 753 |
+
}
|
| 754 |
+
}
|
| 755 |
+
```
|
| 756 |
+
|
| 757 |
+
The co-signatures are each over the canonical-JSON of the payload excluding `co_signers` (i.e. each co-signer signs the revoke intent independently). The caller is one of the co-signers; the bus rejects with `unauthorized` if fewer than 3 distinct trusted signers are present.
|
| 758 |
+
|
| 759 |
+
#### Response
|
| 760 |
+
|
| 761 |
+
```json
|
| 762 |
+
{"output": {"event_id": "01HXR...", "lamport": 4400}, "meta": {"ms": 7}}
|
| 763 |
+
```
|
| 764 |
+
|
| 765 |
+
---
|
| 766 |
+
|
| 767 |
+
## 5. Wire format
|
| 768 |
+
|
| 769 |
+
### 5.1 Request
|
| 770 |
+
|
| 771 |
+
```http
|
| 772 |
+
POST /bus/v1/call HTTP/1.1
|
| 773 |
+
Host: <host>:<port>
|
| 774 |
+
Content-Type: application/json
|
| 775 |
+
Accept: application/json, text/event-stream
|
| 776 |
+
X-HearthNet-Capability: <capability_name>
|
| 777 |
+
X-HearthNet-Capability-Version: <major.minor>
|
| 778 |
+
X-HearthNet-Request-Id: <trace_id_ulid>
|
| 779 |
+
X-HearthNet-From: <full_node_id>
|
| 780 |
+
X-HearthNet-Community: <full_community_id>
|
| 781 |
+
X-HearthNet-Timestamp: <wall_clock>
|
| 782 |
+
X-HearthNet-Signature: <ed25519_signature>
|
| 783 |
+
|
| 784 |
+
<JSON body>
|
| 785 |
+
```
|
| 786 |
+
|
| 787 |
+
The signature covers the canonical JSON of:
|
| 788 |
+
|
| 789 |
+
```json
|
| 790 |
+
{
|
| 791 |
+
"capability": "...",
|
| 792 |
+
"version": "1.0",
|
| 793 |
+
"request_id": "...",
|
| 794 |
+
"from": "...",
|
| 795 |
+
"community": "...",
|
| 796 |
+
"timestamp": "...",
|
| 797 |
+
"body": <request body, canonicalised>
|
| 798 |
+
}
|
| 799 |
+
```
|
| 800 |
+
|
| 801 |
+
Servers verify by reconstructing this object and checking the signature against the caller's pubkey (derived from `X-HearthNet-From`).
|
| 802 |
+
|
| 803 |
+
### 5.2 Response — non-stream
|
| 804 |
+
|
| 805 |
+
```http
|
| 806 |
+
HTTP/1.1 200 OK
|
| 807 |
+
Content-Type: application/json
|
| 808 |
+
X-HearthNet-Request-Id: <trace_id>
|
| 809 |
+
X-HearthNet-From: <server_node_id>
|
| 810 |
+
X-HearthNet-Timestamp: <wall_clock>
|
| 811 |
+
X-HearthNet-Signature: <ed25519 over response>
|
| 812 |
+
|
| 813 |
+
<JSON body>
|
| 814 |
+
```
|
| 815 |
+
|
| 816 |
+
Servers SHOULD sign responses. Clients MAY ignore the signature for non-mutating capabilities. For all `*.post`, `*.invite`, `*.revoke`, `*.ingest`, `*.expire`, `chat.send`, signature verification is mandatory.
|
| 817 |
+
|
| 818 |
+
### 5.3 Response — stream
|
| 819 |
+
|
| 820 |
+
```http
|
| 821 |
+
HTTP/1.1 200 OK
|
| 822 |
+
Content-Type: text/event-stream
|
| 823 |
+
X-HearthNet-Request-Id: <trace_id>
|
| 824 |
+
X-HearthNet-From: <server_node_id>
|
| 825 |
+
|
| 826 |
+
event: <event_name>
|
| 827 |
+
data: <JSON, single line>
|
| 828 |
+
|
| 829 |
+
event: <event_name>
|
| 830 |
+
data: <JSON>
|
| 831 |
+
...
|
| 832 |
+
```
|
| 833 |
+
|
| 834 |
+
Frame events:
|
| 835 |
+
|
| 836 |
+
- `token`, `tool_call_delta`, `chunk`, `manifest`, `ready` — capability-specific
|
| 837 |
+
- `progress` — `data: {"current": N, "total": M, "stage": "..."}` (any capability)
|
| 838 |
+
- `ack` — `data: {"upto": N}` (client → server backpressure)
|
| 839 |
+
- `error` — terminal error frame, replaces `done`
|
| 840 |
+
- `done` — terminal success frame
|
| 841 |
+
|
| 842 |
+
Every stream ends with exactly one of `done` or `error`. After that the connection closes.
|
| 843 |
+
|
| 844 |
+
### 5.4 Error response
|
| 845 |
+
|
| 846 |
+
```http
|
| 847 |
+
HTTP/1.1 <status>
|
| 848 |
+
Content-Type: application/json
|
| 849 |
+
X-HearthNet-Request-Id: <trace_id>
|
| 850 |
+
|
| 851 |
+
{
|
| 852 |
+
"error": "<code>",
|
| 853 |
+
"message": "<human-readable, optional>",
|
| 854 |
+
"retry_after_ms": 2000,
|
| 855 |
+
"alt_capabilities": ["llm.chat@0.9"],
|
| 856 |
+
"alt_nodes": ["ed25519:..."],
|
| 857 |
+
"schema_hash_expected": "blake3:..." // only for schema_mismatch
|
| 858 |
+
}
|
| 859 |
+
```
|
| 860 |
+
|
| 861 |
+
### 5.5 Status code mapping
|
| 862 |
+
|
| 863 |
+
| Status | Error codes |
|
| 864 |
+
|--------|-------------|
|
| 865 |
+
| 200 | (success) |
|
| 866 |
+
| 400 | `bad_request`, `schema_mismatch` |
|
| 867 |
+
| 401 | `invalid_signature`, `unauthorized` |
|
| 868 |
+
| 403 | `revoked` |
|
| 869 |
+
| 404 | `not_found` |
|
| 870 |
+
| 408 | `timeout` |
|
| 871 |
+
| 410 | `expired` |
|
| 872 |
+
| 429 | `rate_limited`, `capacity_exceeded` |
|
| 873 |
+
| 500 | `internal_error` |
|
| 874 |
+
| 501 | `not_implemented` |
|
| 875 |
+
| 503 | `partition` |
|
| 876 |
+
|
| 877 |
+
For streams, an `error` frame replaces these; the HTTP status is 200 because the stream was accepted.
|
| 878 |
+
|
| 879 |
+
---
|
| 880 |
+
|
| 881 |
+
## 6. Manifests
|
| 882 |
+
|
| 883 |
+
### 6.1 Node manifest
|
| 884 |
+
|
| 885 |
+
```json
|
| 886 |
+
{
|
| 887 |
+
"version": 1,
|
| 888 |
+
"contract_version": "1.0",
|
| 889 |
+
"node_id": "ed25519:<full_pubkey>",
|
| 890 |
+
"display_name": "garage-pc",
|
| 891 |
+
"community_id": "ed25519:<full_pubkey>",
|
| 892 |
+
"profile": "anchor",
|
| 893 |
+
"endpoints": [
|
| 894 |
+
{"transport": "https", "host": "192.168.188.25", "port": 7080}
|
| 895 |
+
],
|
| 896 |
+
"hardware": {
|
| 897 |
+
"gpu": "RTX 5090",
|
| 898 |
+
"vram_gb": 32,
|
| 899 |
+
"ram_gb": 128,
|
| 900 |
+
"cpu_cores": 24,
|
| 901 |
+
"disk_free_gb": 4000
|
| 902 |
+
},
|
| 903 |
+
"capabilities": [
|
| 904 |
+
{
|
| 905 |
+
"name": "llm.chat",
|
| 906 |
+
"version": "1.0",
|
| 907 |
+
"stability": "stable",
|
| 908 |
+
"schema_hash": "blake3:...",
|
| 909 |
+
"params": {"model": "qwen2.5-7b-instruct", "quant": "q4_k_m", "ctx": 8192},
|
| 910 |
+
"max_concurrent": 4
|
| 911 |
+
}
|
| 912 |
+
],
|
| 913 |
+
"uptime_seconds": 43210,
|
| 914 |
+
"load": {"cpu": 0.12, "vram_used_gb": 6.4, "in_flight_total": 0},
|
| 915 |
+
"issued_at": "2026-05-26T08:14:22Z",
|
| 916 |
+
"expires_at": "2026-05-26T08:14:52Z",
|
| 917 |
+
"signature": "ed25519:..."
|
| 918 |
+
}
|
| 919 |
+
```
|
| 920 |
+
|
| 921 |
+
#### Rules
|
| 922 |
+
|
| 923 |
+
- `expires_at - issued_at == 30 s` exactly
|
| 924 |
+
- Re-issued every `MANIFEST_REPUBLISH_INTERVAL_SECONDS` (20s)
|
| 925 |
+
- Signed by the node's device key
|
| 926 |
+
- Stale manifests (past `expires_at`) are rejected with `expired`
|
| 927 |
+
- Verifying nodes pin the first-seen public key per `node_id`; a later manifest with a different key for the same `node_id` is rejected with `invalid_signature`
|
| 928 |
+
|
| 929 |
+
### 6.2 Community manifest
|
| 930 |
+
|
| 931 |
+
```json
|
| 932 |
+
{
|
| 933 |
+
"version": 1,
|
| 934 |
+
"community_id": "ed25519:<root_pubkey>",
|
| 935 |
+
"name": "Niederrhein Demo",
|
| 936 |
+
"root_key": "ed25519:<root_pubkey>",
|
| 937 |
+
"created_at": "2026-05-26T08:00:00Z",
|
| 938 |
+
"lamport_at_creation": 0,
|
| 939 |
+
"policy": {
|
| 940 |
+
"min_signatures_to_invite": 1,
|
| 941 |
+
"min_signatures_to_demote": 3,
|
| 942 |
+
"min_signatures_to_revoke": 3,
|
| 943 |
+
"capability_token_ttl_seconds": 86400,
|
| 944 |
+
"federation_enabled": true,
|
| 945 |
+
"default_member_can_invite": true
|
| 946 |
+
},
|
| 947 |
+
"members": [
|
| 948 |
+
{
|
| 949 |
+
"node_id": "ed25519:...",
|
| 950 |
+
"level": "anchor",
|
| 951 |
+
"added_at": "2026-05-26T08:00:00Z",
|
| 952 |
+
"added_by": "ed25519:..."
|
| 953 |
+
}
|
| 954 |
+
],
|
| 955 |
+
"revoked": [
|
| 956 |
+
{"node_id": "ed25519:...", "revoked_at": "..."}
|
| 957 |
+
],
|
| 958 |
+
"head_lamport": 4218,
|
| 959 |
+
"signature": "ed25519:..."
|
| 960 |
+
}
|
| 961 |
+
```
|
| 962 |
+
|
| 963 |
+
The community manifest is **derived** from the event log. It is the materialised view at `head_lamport`. It is signed by either the root key (initial creation) or any anchor (subsequent regeneration). Other nodes verify regenerations by replaying events from the previous head.
|
| 964 |
+
|
| 965 |
+
---
|
| 966 |
+
|
| 967 |
+
## 7. Events (the community log)
|
| 968 |
+
|
| 969 |
+
### 7.1 Common event envelope
|
| 970 |
+
|
| 971 |
+
```json
|
| 972 |
+
{
|
| 973 |
+
"schema_version": 1,
|
| 974 |
+
"event_id": "01HXR...", // ULID
|
| 975 |
+
"lamport": 4218,
|
| 976 |
+
"wall_clock": "2026-05-26T08:14:22Z",
|
| 977 |
+
"community_id": "ed25519:...",
|
| 978 |
+
"author": "ed25519:...",
|
| 979 |
+
"event_type": "market.post.created",
|
| 980 |
+
"data": { /* type-specific */ },
|
| 981 |
+
"signature": "ed25519:..." // over canonical JSON of all above
|
| 982 |
+
}
|
| 983 |
+
```
|
| 984 |
+
|
| 985 |
+
### 7.2 Canonical event types
|
| 986 |
+
|
| 987 |
+
For each: `data` schema, who may produce, who consumes.
|
| 988 |
+
|
| 989 |
+
#### `community.created`
|
| 990 |
+
|
| 991 |
+
```json
|
| 992 |
+
{
|
| 993 |
+
"name": "Niederrhein Demo",
|
| 994 |
+
"founder_node_id": "ed25519:...",
|
| 995 |
+
"policy": { /* full policy as in community manifest */ }
|
| 996 |
+
}
|
| 997 |
+
```
|
| 998 |
+
|
| 999 |
+
Producer: founder, exactly once at community birth.
|
| 1000 |
+
Consumer: all.
|
| 1001 |
+
|
| 1002 |
+
#### `community.member.invited`
|
| 1003 |
+
|
| 1004 |
+
```json
|
| 1005 |
+
{
|
| 1006 |
+
"invitee_node_id": "ed25519:...",
|
| 1007 |
+
"display_name": "Hannes' Tablet",
|
| 1008 |
+
"initial_level": "member",
|
| 1009 |
+
"expires_at": "2026-05-27T00:00:00Z"
|
| 1010 |
+
}
|
| 1011 |
+
```
|
| 1012 |
+
|
| 1013 |
+
Producer: any member with `can_invite`.
|
| 1014 |
+
Consumer: all.
|
| 1015 |
+
|
| 1016 |
+
#### `community.member.joined`
|
| 1017 |
+
|
| 1018 |
+
```json
|
| 1019 |
+
{
|
| 1020 |
+
"invite_event_id": "01HXR...",
|
| 1021 |
+
"node_manifest": { /* full manifest at join time */ }
|
| 1022 |
+
}
|
| 1023 |
+
```
|
| 1024 |
+
|
| 1025 |
+
Producer: the invitee, on first connection.
|
| 1026 |
+
Consumer: all.
|
| 1027 |
+
|
| 1028 |
+
#### `community.member.revoked`
|
| 1029 |
+
|
| 1030 |
+
```json
|
| 1031 |
+
{
|
| 1032 |
+
"target_node_id": "ed25519:...",
|
| 1033 |
+
"reason": "compromised",
|
| 1034 |
+
"co_signers": [{"node_id":"...", "signature":"..."}, ...]
|
| 1035 |
+
}
|
| 1036 |
+
```
|
| 1037 |
+
|
| 1038 |
+
Producer: any trusted member who has gathered 3 co-signatures.
|
| 1039 |
+
Consumer: all.
|
| 1040 |
+
|
| 1041 |
+
#### `community.member.promoted` / `community.member.demoted`
|
| 1042 |
+
|
| 1043 |
+
```json
|
| 1044 |
+
{
|
| 1045 |
+
"target_node_id": "ed25519:...",
|
| 1046 |
+
"new_level": "trusted",
|
| 1047 |
+
"co_signers": [...]
|
| 1048 |
+
}
|
| 1049 |
+
```
|
| 1050 |
+
|
| 1051 |
+
Producer: trusted member with required signatures (1 promote, 3 demote).
|
| 1052 |
+
Consumer: all.
|
| 1053 |
+
|
| 1054 |
+
#### `community.policy.updated`
|
| 1055 |
+
|
| 1056 |
+
```json
|
| 1057 |
+
{
|
| 1058 |
+
"policy": { /* new policy */ }
|
| 1059 |
+
}
|
| 1060 |
+
```
|
| 1061 |
+
|
| 1062 |
+
Producer: root key only.
|
| 1063 |
+
Consumer: all.
|
| 1064 |
+
|
| 1065 |
+
#### `node.manifest.updated`
|
| 1066 |
+
|
| 1067 |
+
```json
|
| 1068 |
+
{
|
| 1069 |
+
"manifest": { /* full node manifest */ }
|
| 1070 |
+
}
|
| 1071 |
+
```
|
| 1072 |
+
|
| 1073 |
+
Producer: each node, advisory; not strictly required in the log but useful for replay-based audit.
|
| 1074 |
+
Consumer: all.
|
| 1075 |
+
|
| 1076 |
+
#### `market.post.created`
|
| 1077 |
+
|
| 1078 |
+
```json
|
| 1079 |
+
{
|
| 1080 |
+
"client_id": "...",
|
| 1081 |
+
"category": "request",
|
| 1082 |
+
"title": "...",
|
| 1083 |
+
"body": "...",
|
| 1084 |
+
"location": {"lat":..., "lng":..., "label":"..."},
|
| 1085 |
+
"tags": ["..."],
|
| 1086 |
+
"ttl_seconds": 86400
|
| 1087 |
+
}
|
| 1088 |
+
```
|
| 1089 |
+
|
| 1090 |
+
Producer: any member.
|
| 1091 |
+
Consumer: all.
|
| 1092 |
+
|
| 1093 |
+
#### `market.post.updated`
|
| 1094 |
+
|
| 1095 |
+
```json
|
| 1096 |
+
{
|
| 1097 |
+
"client_id": "...",
|
| 1098 |
+
"target_event_id": "01HXR...",
|
| 1099 |
+
"fields": {"body": "..."}
|
| 1100 |
+
}
|
| 1101 |
+
```
|
| 1102 |
+
|
| 1103 |
+
Producer: original author only.
|
| 1104 |
+
Consumer: all.
|
| 1105 |
+
|
| 1106 |
+
#### `market.post.expired`
|
| 1107 |
+
|
| 1108 |
+
```json
|
| 1109 |
+
{
|
| 1110 |
+
"client_id": "...",
|
| 1111 |
+
"target_event_id": "01HXR...",
|
| 1112 |
+
"reason": "fulfilled|withdrawn|user_request|stale"
|
| 1113 |
+
}
|
| 1114 |
+
```
|
| 1115 |
+
|
| 1116 |
+
Producer: original author OR any trusted moderator (with `reason != "user_request"`).
|
| 1117 |
+
Consumer: all.
|
| 1118 |
+
|
| 1119 |
+
#### `chat.message.sent`
|
| 1120 |
+
|
| 1121 |
+
```json
|
| 1122 |
+
{
|
| 1123 |
+
"client_id": "...",
|
| 1124 |
+
"recipient": "ed25519:...",
|
| 1125 |
+
"body": "...",
|
| 1126 |
+
"attachments": [{"cid":"...","name":"..."}]
|
| 1127 |
+
}
|
| 1128 |
+
```
|
| 1129 |
+
|
| 1130 |
+
Producer: sender.
|
| 1131 |
+
Consumer: sender + recipient (others may see envelope only, but `data` MAY be encrypted in Phase 2).
|
| 1132 |
+
|
| 1133 |
+
#### `chat.message.delivered`
|
| 1134 |
+
|
| 1135 |
+
```json
|
| 1136 |
+
{"target_event_id": "01HXR...", "delivered_at": "..."}
|
| 1137 |
+
```
|
| 1138 |
+
|
| 1139 |
+
Producer: recipient.
|
| 1140 |
+
Consumer: sender.
|
| 1141 |
+
|
| 1142 |
+
#### `chat.message.read`
|
| 1143 |
+
|
| 1144 |
+
```json
|
| 1145 |
+
{"target_event_id": "01HXR...", "read_at": "..."}
|
| 1146 |
+
```
|
| 1147 |
+
|
| 1148 |
+
Producer: recipient (optional, may be disabled by user).
|
| 1149 |
+
Consumer: sender.
|
| 1150 |
+
|
| 1151 |
+
#### `file.cid.advertised`
|
| 1152 |
+
|
| 1153 |
+
```json
|
| 1154 |
+
{"cid": "blake3:...", "sizes_bytes": 4824711}
|
| 1155 |
+
```
|
| 1156 |
+
|
| 1157 |
+
Producer: holder.
|
| 1158 |
+
Consumer: all (used for fan-out file discovery).
|
| 1159 |
+
|
| 1160 |
+
#### `file.cid.unpinned`
|
| 1161 |
+
|
| 1162 |
+
```json
|
| 1163 |
+
{"cid": "blake3:..."}
|
| 1164 |
+
```
|
| 1165 |
+
|
| 1166 |
+
Producer: holder.
|
| 1167 |
+
Consumer: all.
|
| 1168 |
+
|
| 1169 |
+
#### `rag.document.ingested`
|
| 1170 |
+
|
| 1171 |
+
```json
|
| 1172 |
+
{
|
| 1173 |
+
"corpus": "niederrhein-emergency",
|
| 1174 |
+
"doc_cid": "blake3:...",
|
| 1175 |
+
"title": "...",
|
| 1176 |
+
"language": "de",
|
| 1177 |
+
"chunks": 87
|
| 1178 |
+
}
|
| 1179 |
+
```
|
| 1180 |
+
|
| 1181 |
+
Producer: ingester.
|
| 1182 |
+
Consumer: all members.
|
| 1183 |
+
|
| 1184 |
+
#### `federation.peer.added` / `federation.peer.removed` (Phase 2)
|
| 1185 |
+
|
| 1186 |
+
Reserved.
|
| 1187 |
+
|
| 1188 |
+
### 7.3 Lamport rules
|
| 1189 |
+
|
| 1190 |
+
Every node maintains a per-community Lamport counter.
|
| 1191 |
+
|
| 1192 |
+
```
|
| 1193 |
+
on send: lamport_send = ++lamport
|
| 1194 |
+
on receive: lamport = max(lamport, received.lamport) + 1
|
| 1195 |
+
```
|
| 1196 |
+
|
| 1197 |
+
### 7.4 Ordering
|
| 1198 |
+
|
| 1199 |
+
- Replay order: by `lamport` ascending, tie-broken by `event_id` ascending (ULIDs sort by time naturally)
|
| 1200 |
+
- Conflict resolution: last-writer-wins by Lamport; `community.member.revoked` is checked first when replaying actions by the revoked party
|
| 1201 |
+
|
| 1202 |
+
### 7.5 Snapshots
|
| 1203 |
+
|
| 1204 |
+
A snapshot at Lamport L is:
|
| 1205 |
+
|
| 1206 |
+
```json
|
| 1207 |
+
{
|
| 1208 |
+
"schema_version": 1,
|
| 1209 |
+
"community_id": "ed25519:...",
|
| 1210 |
+
"lamport": <L>,
|
| 1211 |
+
"wall_clock": "...",
|
| 1212 |
+
"state": { /* materialised views: community manifest, marketplace_current, ... */ },
|
| 1213 |
+
"covers_events_up_to": <L>,
|
| 1214 |
+
"signature": "ed25519:..."
|
| 1215 |
+
}
|
| 1216 |
+
```
|
| 1217 |
+
|
| 1218 |
+
Signed by any anchor. Other nodes verify the signature and the membership of the signer.
|
| 1219 |
+
|
| 1220 |
+
### 7.6 Sync protocol (gossip)
|
| 1221 |
+
|
| 1222 |
+
Two nodes meeting:
|
| 1223 |
+
|
| 1224 |
+
1. A → B: `GET /sync/v1/heads` → returns `{community_id: max_lamport}` per known community
|
| 1225 |
+
2. A computes delta; for each community where A is ahead, A → B: `POST /sync/v1/events` with all events `lamport > B.head`
|
| 1226 |
+
3. B verifies signatures, applies, returns `{accepted, rejected, new_head_lamport}`
|
| 1227 |
+
4. Roles reverse; B sends what A is missing
|
| 1228 |
+
|
| 1229 |
+
This is also covered in [X02 §6](cross-cutting/X02-events.md).
|
| 1230 |
+
|
| 1231 |
+
---
|
| 1232 |
+
|
| 1233 |
+
## 8. Pub-sub topics
|
| 1234 |
+
|
| 1235 |
+
Topics are used for live notifications between connected peers (in addition to durable events in the log).
|
| 1236 |
+
|
| 1237 |
+
| Topic | Payload | Producer | Subscriber |
|
| 1238 |
+
|-------|---------|----------|------------|
|
| 1239 |
+
| `community.member.added` | event envelope | any member | all |
|
| 1240 |
+
| `community.member.revoked` | event envelope | trusted | all |
|
| 1241 |
+
| `node.manifest.updated` | manifest | each node | all |
|
| 1242 |
+
| `marketplace.post.created` | event envelope | any member | all |
|
| 1243 |
+
| `marketplace.post.expired` | event envelope | author or trusted | all |
|
| 1244 |
+
| `chat.message.<recipient>` | event envelope | sender | recipient |
|
| 1245 |
+
| `emergency.mode.changed` | `{online: bool, since: "..."}` | each node locally | local UI only — never on the wire |
|
| 1246 |
+
| `federation.peer.added` | event envelope | anchor | all anchors |
|
| 1247 |
+
| `capability.registered` | descriptor | local bus | local UI only |
|
| 1248 |
+
| `capability.deregistered` | name+version | local bus | local UI only |
|
| 1249 |
+
|
| 1250 |
+
Transport: HTTP long-polling for MVP (`GET /pubsub/v1/subscribe?topic=...`), WebSocket in Phase 2.
|
| 1251 |
+
|
| 1252 |
+
---
|
| 1253 |
+
|
| 1254 |
+
## 9. Error codes (complete reference)
|
| 1255 |
+
|
| 1256 |
+
| Code | When | Retry? |
|
| 1257 |
+
|------|------|--------|
|
| 1258 |
+
| `bad_request` | Malformed payload | no, fix and resend |
|
| 1259 |
+
| `schema_mismatch` | Schema hash differs | no, upgrade and resend |
|
| 1260 |
+
| `invalid_signature` | Signature verification failed | no |
|
| 1261 |
+
| `unauthorized` | Caller lacks required trust level | no |
|
| 1262 |
+
| `revoked` | Caller's NodeID is revoked | no |
|
| 1263 |
+
| `expired` | Manifest or token past `expires_at` | no, re-issue and resend |
|
| 1264 |
+
| `not_found` | Resource doesn't exist | no |
|
| 1265 |
+
| `not_implemented` | Capability declared but unimplemented | no |
|
| 1266 |
+
| `timeout` | Exceeded server-side deadline | yes, with backoff |
|
| 1267 |
+
| `partition` | Peer unreachable | yes, with backoff |
|
| 1268 |
+
| `capacity_exceeded` | Concurrent limit reached | yes, honour `retry_after_ms` |
|
| 1269 |
+
| `rate_limited` | Rate budget exceeded | yes, honour `retry_after_ms` |
|
| 1270 |
+
| `internal_error` | Server-side bug or crash | maybe, idempotent capabilities only |
|
| 1271 |
+
|
| 1272 |
+
---
|
| 1273 |
+
|
| 1274 |
+
## 10. Signing reference
|
| 1275 |
+
|
| 1276 |
+
```python
|
| 1277 |
+
# canonical_json(obj) → bytes (sorted keys, no whitespace, no trailing zeros on floats)
|
| 1278 |
+
|
| 1279 |
+
def sign(payload: dict, sk: SigningKey) -> dict:
|
| 1280 |
+
p = {k: v for k, v in payload.items() if k != "signature"}
|
| 1281 |
+
msg = canonical_json(p)
|
| 1282 |
+
sig = sk.sign(msg).signature
|
| 1283 |
+
p["signature"] = f"ed25519:{base64url_nopad(sig)}"
|
| 1284 |
+
return p
|
| 1285 |
+
|
| 1286 |
+
def verify(payload: dict, vk: VerifyKey) -> bool:
|
| 1287 |
+
sig_field = payload.get("signature", "")
|
| 1288 |
+
if not sig_field.startswith("ed25519:"):
|
| 1289 |
+
return False
|
| 1290 |
+
sig = base64url_nopad_decode(sig_field[len("ed25519:"):])
|
| 1291 |
+
p = {k: v for k, v in payload.items() if k != "signature"}
|
| 1292 |
+
msg = canonical_json(p)
|
| 1293 |
+
try:
|
| 1294 |
+
vk.verify(msg, sig)
|
| 1295 |
+
return True
|
| 1296 |
+
except BadSignature:
|
| 1297 |
+
return False
|
| 1298 |
+
```
|
| 1299 |
+
|
| 1300 |
+
For HTTP requests, the signed payload is the synthetic envelope of §5.1, not the raw body.
|
| 1301 |
+
|
| 1302 |
+
---
|
| 1303 |
+
|
| 1304 |
+
## 11. Schema hash computation
|
| 1305 |
+
|
| 1306 |
+
For a capability descriptor, the schema hash is BLAKE3 of the canonical JSON of:
|
| 1307 |
+
|
| 1308 |
+
```json
|
| 1309 |
+
{
|
| 1310 |
+
"name": "<capability_name>",
|
| 1311 |
+
"version": "<major.minor>",
|
| 1312 |
+
"request_schema": <JSON Schema>,
|
| 1313 |
+
"response_schema": <JSON Schema or null>,
|
| 1314 |
+
"stream_schema": <JSON Schema or null>
|
| 1315 |
+
}
|
| 1316 |
+
```
|
| 1317 |
+
|
| 1318 |
+
If two implementations want to interoperate without reading docs, they MUST produce the same schema hash. Treat schemas as data; never use language-specific Pydantic features in the schema (extra `discriminator`, etc.) without normalising first.
|
| 1319 |
+
|
| 1320 |
+
---
|
| 1321 |
+
|
| 1322 |
+
## 12. Open questions tracked here
|
| 1323 |
+
|
| 1324 |
+
1. **Encrypted chat (Phase 2)** — when added, `chat.message.sent.data.body` becomes ciphertext; envelope (`event_type`, `author`, `recipient`, `lamport`) stays in cleartext. The signature still covers the ciphertext.
|
| 1325 |
+
2. **Multi-party group chat** — out of scope this release. Reserved event type `chat.group.*`.
|
| 1326 |
+
3. **Federation manifest** — when added, will look like a community manifest with `peers: []` field. Reserved.
|
| 1327 |
+
4. **WebSocket upgrade** — when added, the `/bus/v1/call` endpoint will accept an `Upgrade: websocket` header; behaviour is otherwise the same.
|
| 1328 |
+
5. **Tool calls in `llm.chat`** — declared as Phase 2; the stream frame `tool_call_delta` is reserved.
|
| 1329 |
+
|
| 1330 |
+
---
|
| 1331 |
+
|
| 1332 |
+
*End of HearthNet Capability Contract v1.0.*
|
docs/GLOSSARY.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# HearthNet Glossary
|
| 2 |
+
|
| 3 |
+
Canonical names. Every spec uses these. Do not introduce synonyms.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## Identifiers
|
| 8 |
+
|
| 9 |
+
| Term | Form | Notes |
|
| 10 |
+
|------|------|-------|
|
| 11 |
+
| **NodeID** | `ed25519:XXXX-XXXX-XXXX-XXXX` | First 8 bytes of Ed25519 public key, base32, grouped by 4 |
|
| 12 |
+
| **NodeID (full)** | `ed25519:<base64-url-nopad>` of full pubkey | Used only in manifests; never displayed to users |
|
| 13 |
+
| **CommunityID** | `ed25519:<...>` of community root pubkey | Same format as full NodeID |
|
| 14 |
+
| **CapabilityName** | dotted lowercase: `llm.chat`, `rag.query` | Namespace allocation: see CONTRACT §3.1 |
|
| 15 |
+
| **Version** | `(major, minor)` tuple — wire form `"1.0"` | Patch component does not exist on the wire |
|
| 16 |
+
| **SchemaHash** | `blake3:<hex>` | BLAKE3 of canonical-JSON of capability schema |
|
| 17 |
+
| **CID** | `blake3:<hex>` | Content identifier for blobs |
|
| 18 |
+
| **EventID** | ULID (26 chars, monotonic, encodes wall-clock) | Globally unique per community in practice |
|
| 19 |
+
| **TraceID** | ULID | Identifies one logical request across hops |
|
| 20 |
+
| **Signature** | `ed25519:<base64-url-nopad>` | Always over canonical-JSON |
|
| 21 |
+
| **WallClock** | RFC 3339 UTC: `2026-05-26T08:14:22Z` | Display-only; never used for ordering |
|
| 22 |
+
| **Lamport** | `int >= 0` | Per-community monotonic logical counter |
|
| 23 |
+
| **Topic** | dotted lowercase: `marketplace.post.created` | Pub-sub topic name |
|
| 24 |
+
|
| 25 |
+
---
|
| 26 |
+
|
| 27 |
+
## Concepts
|
| 28 |
+
|
| 29 |
+
| Term | Definition |
|
| 30 |
+
|------|------------|
|
| 31 |
+
| **Anchor** | A node profile: always-on, GPU-equipped, primary capability provider |
|
| 32 |
+
| **Hearth** | A node profile: mid-tier, typically a laptop, runs some services |
|
| 33 |
+
| **Spark** | A node profile: thin client (Pi, mobile, browser); consumes capabilities |
|
| 34 |
+
| **Bridge** | A node profile: relay-only, no inference, federates communities (Phase 2) |
|
| 35 |
+
| **Profile** | One of `anchor` / `hearth` / `spark` / `bridge` — declared in node manifest |
|
| 36 |
+
| **Community** | A trust root: a group of nodes sharing one root key and one event log |
|
| 37 |
+
| **Federation** | Cross-community trust + capability access (Phase 2) |
|
| 38 |
+
| **Capability** | A named, versioned, schema-bound RPC offered by a node |
|
| 39 |
+
| **Capability descriptor** | The metadata for one capability: name, version, schema, params, guarantees |
|
| 40 |
+
| **Capability entry** | The bus's local record of one remote capability: descriptor + health |
|
| 41 |
+
| **Bus / capability bus** | The L3 routing component each service registers with |
|
| 42 |
+
| **Service** | An L4 module that provides one or more capabilities |
|
| 43 |
+
| **Node manifest** | A signed JSON document describing what a node is and offers; expires every 30s |
|
| 44 |
+
| **Community manifest** | A signed JSON document describing community membership and policy |
|
| 45 |
+
| **Event** | A signed, Lamport-stamped record in the community's append-only log |
|
| 46 |
+
| **Event log** | The community's full ordered history; one SQLite db per node |
|
| 47 |
+
| **Snapshot** | A signed materialised state at some Lamport |
|
| 48 |
+
| **Materialised view** | A derived index built by replaying events (member list, marketplace, etc.) |
|
| 49 |
+
| **Blob** | An immutable byte string identified by its CID |
|
| 50 |
+
| **Chunk** | A 256KB slice of a blob, itself CID-addressed |
|
| 51 |
+
| **TrustLevel** | One of `unknown` / `member` / `trusted` / `anchor` |
|
| 52 |
+
| **Stability** | One of `experimental` / `beta` / `stable` for a capability |
|
| 53 |
+
| **Emergency mode** | UI + behavioural state when the internet-detector reports offline |
|
| 54 |
+
|
| 55 |
+
---
|
| 56 |
+
|
| 57 |
+
## Errors (closed set — see CONTRACT §9)
|
| 58 |
+
|
| 59 |
+
| Code | Meaning |
|
| 60 |
+
|------|---------|
|
| 61 |
+
| `not_found` | No node offers the requested capability, or the requested resource is gone |
|
| 62 |
+
| `capacity_exceeded` | Node is at declared `max_concurrent`; retry with backoff or pick another |
|
| 63 |
+
| `schema_mismatch` | Request body does not match the declared schema for this capability/version |
|
| 64 |
+
| `unauthorized` | Caller is not a community member, or lacks the required trust level |
|
| 65 |
+
| `revoked` | Caller's NodeID is in the revoked set of the community |
|
| 66 |
+
| `internal_error` | Service crashed handling this request |
|
| 67 |
+
| `not_implemented` | Capability is declared but the handler is a stub |
|
| 68 |
+
| `timeout` | Operation exceeded its declared deadline |
|
| 69 |
+
| `partition` | Remote node is presumed unreachable (mDNS lost, repeated failures) |
|
| 70 |
+
| `invalid_signature` | Signature verification failed on a manifest, event, or request |
|
| 71 |
+
| `expired` | Manifest, token, or event TTL has passed |
|
| 72 |
+
| `rate_limited` | Caller exceeded the per-peer-per-capability rate budget |
|
| 73 |
+
| `bad_request` | Malformed JSON, missing required field, etc. |
|
| 74 |
+
|
| 75 |
+
---
|
| 76 |
+
|
| 77 |
+
## File paths (XDG-style; resolved via `platformdirs`)
|
| 78 |
+
|
| 79 |
+
| Path | Purpose |
|
| 80 |
+
|------|---------|
|
| 81 |
+
| `<DATA>/keys/device.ed25519` | Private key, 0600 |
|
| 82 |
+
| `<DATA>/keys/device.pub` | Public key |
|
| 83 |
+
| `<DATA>/communities/<community_id>/manifest.json` | Latest signed community manifest |
|
| 84 |
+
| `<DATA>/communities/<community_id>/events.sqlite` | Event log |
|
| 85 |
+
| `<DATA>/communities/<community_id>/snapshots/<lamport>.bin` | Signed snapshots |
|
| 86 |
+
| `<DATA>/blobs/<aa>/<bb...>` | CID-addressed blobs |
|
| 87 |
+
| `<CONFIG>/config.toml` | User configuration |
|
| 88 |
+
| `<CACHE>/embeddings/<corpus>` | Vector store on-disk files |
|
| 89 |
+
| `<LOG>/<date>.log` | Daily rotating logs |
|
| 90 |
+
|
| 91 |
+
`<DATA>` = `platformdirs.user_data_dir("hearthnet")` (Linux: `~/.local/share/hearthnet/`).
|
| 92 |
+
|
| 93 |
+
---
|
| 94 |
+
|
| 95 |
+
## Default ports
|
| 96 |
+
|
| 97 |
+
| Port | Purpose | Configurable? |
|
| 98 |
+
|------|---------|---------------|
|
| 99 |
+
| 7080 | Bus HTTP server | yes (`config.transport.port`) |
|
| 100 |
+
| 7860 | Gradio UI | yes (`config.ui.port`) |
|
| 101 |
+
| 42424 | UDP discovery multicast | no (interop) |
|
| 102 |
+
| 5353 | mDNS | no (system) |
|
| 103 |
+
|
| 104 |
+
---
|
| 105 |
+
|
| 106 |
+
## Defaults (numeric — central reference)
|
| 107 |
+
|
| 108 |
+
| Constant | Value | Where defined |
|
| 109 |
+
|----------|-------|---------------|
|
| 110 |
+
| `MANIFEST_TTL_SECONDS` | 30 | M01 |
|
| 111 |
+
| `MANIFEST_REPUBLISH_INTERVAL_SECONDS` | 20 | M01 |
|
| 112 |
+
| `DISCOVERY_UDP_INTERVAL_SECONDS` | 5 (active) / 30 (stable) | M02 |
|
| 113 |
+
| `EMERGENCY_PROBE_INTERVAL_SECONDS` | 10 (online) / 2 (offline) | M09 |
|
| 114 |
+
| `EMERGENCY_PROBE_TIMEOUT_SECONDS` | 2 | M09 |
|
| 115 |
+
| `EMERGENCY_TRANSITION_DEBOUNCE_SECONDS` | 30 | M09 |
|
| 116 |
+
| `CONNECTION_IDLE_SECONDS` | 60 | X01 |
|
| 117 |
+
| `RECONNECT_BACKOFF_CAP_SECONDS` | 30 | X01 |
|
| 118 |
+
| `STREAM_WINDOW_FRAMES` | 16 | X01 |
|
| 119 |
+
| `STREAM_ACK_INTERVAL_FRAMES` | 8 | X01 |
|
| 120 |
+
| `STREAM_ACK_TIMEOUT_SECONDS` | 5 | X01 |
|
| 121 |
+
| `RPC_DEFAULT_TIMEOUT_SECONDS` | 30 | X01 |
|
| 122 |
+
| `LLM_GENERATION_DEFAULT_TIMEOUT_SECONDS` | 120 | M04 |
|
| 123 |
+
| `CHUNK_SIZE_BYTES` | 262144 (256 KiB) | M07 |
|
| 124 |
+
| `BLOB_GC_DISK_THRESHOLD` | 0.80 | M07 |
|
| 125 |
+
| `RAG_CHUNK_TOKENS` | 1000 | M05 |
|
| 126 |
+
| `RAG_CHUNK_OVERLAP_TOKENS` | 200 | M05 |
|
| 127 |
+
| `RAG_DEFAULT_K` | 5 | M05 |
|
| 128 |
+
| `RAG_MAX_K` | 20 | M05 |
|
| 129 |
+
| `HEALTH_WINDOW_CALLS` | 100 | M03 |
|
| 130 |
+
| `HEALTH_QUARANTINE_THRESHOLD` | 0.5 | M03 |
|
| 131 |
+
| `HEALTH_QUARANTINE_SECONDS` | 60 | M03 |
|
| 132 |
+
| `RATE_LIMIT_SOFT_RPS_PER_CAP` | 10 | X01 |
|
| 133 |
+
| `RATE_LIMIT_HARD_RPS_PER_CAP` | 100 | X01 |
|
| 134 |
+
| `RATE_LIMIT_SOFT_RPS_TOTAL` | 100 | X01 |
|
| 135 |
+
| `RATE_LIMIT_HARD_RPS_TOTAL` | 1000 | X01 |
|
| 136 |
+
| `EVENT_LOG_RETENTION_DAYS` | 30 | X02 |
|
| 137 |
+
| `SNAPSHOT_LAG_LAMPORT` | 1000 | X02 |
|
| 138 |
+
| `TRACE_RING_BUFFER` | 10000 | X03 |
|
| 139 |
+
| `LOG_RETENTION_DAYS` | 14 | X03 |
|
| 140 |
+
|
| 141 |
+
Implementation hint: put these in `hearthnet/constants.py` and import everywhere. Do not hardcode in modules.
|
docs/M01-identity.md
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# M01 — Identity & Manifests
|
| 2 |
+
|
| 3 |
+
**Spec version:** v1.0
|
| 4 |
+
**Depends on:** X04 (config), PyNaCl, cryptography (for TLS cert generation)
|
| 5 |
+
**Depended on by:** X02 (events), X01 (transport), M02 (discovery), M03 (bus), and every service that signs anything
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 1. Responsibility
|
| 10 |
+
|
| 11 |
+
- Cryptographic identity (Ed25519 keypair) for one device
|
| 12 |
+
- Signing and verification of arbitrary canonical-JSON payloads
|
| 13 |
+
- Creation, signing, verification of **node manifests**
|
| 14 |
+
- Creation, signing, verification of **community manifests**
|
| 15 |
+
- Canonical JSON encoding (the one that signatures are computed over)
|
| 16 |
+
- TLS certificate generation (self-signed, pinned to the device key on first contact)
|
| 17 |
+
- Optional: capability tokens (Phase 2)
|
| 18 |
+
|
| 19 |
+
This module owns no event log — that is X02. It owns no networking — that is X01. It owns only crypto and document formats.
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
## 2. File layout
|
| 24 |
+
|
| 25 |
+
```
|
| 26 |
+
hearthnet/identity/
|
| 27 |
+
├── __init__.py # re-exports
|
| 28 |
+
├── keys.py # KeyPair, canonical_json, sign, verify, TLS cert
|
| 29 |
+
├── manifest.py # NodeManifest, CommunityManifest, builders, verifiers
|
| 30 |
+
└── tokens.py # Phase 2; stubs only in MVP
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
---
|
| 34 |
+
|
| 35 |
+
## 3. Public API
|
| 36 |
+
|
| 37 |
+
### 3.1 `keys.py`
|
| 38 |
+
|
| 39 |
+
```python
|
| 40 |
+
# hearthnet/identity/keys.py
|
| 41 |
+
from dataclasses import dataclass
|
| 42 |
+
from pathlib import Path
|
| 43 |
+
from typing import Any
|
| 44 |
+
|
| 45 |
+
NodeID = str # "ed25519:XXXX-XXXX-XXXX-XXXX" (short) or "ed25519:<base64-url-nopad>" (full)
|
| 46 |
+
Signature = str # "ed25519:<base64-url-nopad>"
|
| 47 |
+
|
| 48 |
+
@dataclass(frozen=True)
|
| 49 |
+
class KeyPair:
|
| 50 |
+
"""Wraps Ed25519 signing key + verify key plus their string forms."""
|
| 51 |
+
signing_key: nacl.signing.SigningKey
|
| 52 |
+
verify_key: nacl.signing.VerifyKey
|
| 53 |
+
node_id_full: str # "ed25519:<base64-url-nopad of 32 bytes>"
|
| 54 |
+
node_id_short: str # "ed25519:XXXX-XXXX-XXXX-XXXX" (8 bytes, base32, 4-grouped)
|
| 55 |
+
|
| 56 |
+
def sign(self, payload: dict) -> dict:
|
| 57 |
+
"""Return a new dict equal to payload with signature field added."""
|
| 58 |
+
|
| 59 |
+
def sign_bytes(self, data: bytes) -> Signature:
|
| 60 |
+
"""Sign raw bytes; return 'ed25519:<base64-url-nopad>'."""
|
| 61 |
+
|
| 62 |
+
# --- generation / loading ---
|
| 63 |
+
|
| 64 |
+
def generate() -> KeyPair:
|
| 65 |
+
"""Create a fresh Ed25519 keypair (uses os.urandom)."""
|
| 66 |
+
|
| 67 |
+
def load(keys_dir: Path) -> KeyPair:
|
| 68 |
+
"""Load device.ed25519 + device.pub from keys_dir.
|
| 69 |
+
Raises IdentityError('keys_missing') if not present,
|
| 70 |
+
IdentityError('keys_invalid') if malformed.
|
| 71 |
+
Validates permissions are 0600 on Unix."""
|
| 72 |
+
|
| 73 |
+
def load_or_generate(keys_dir: Path) -> KeyPair:
|
| 74 |
+
"""Load if present; otherwise generate and persist."""
|
| 75 |
+
|
| 76 |
+
def save(kp: KeyPair, keys_dir: Path) -> None:
|
| 77 |
+
"""Persist signing key as device.ed25519 (0600) and verify key as device.pub."""
|
| 78 |
+
|
| 79 |
+
# --- public-key handling ---
|
| 80 |
+
|
| 81 |
+
def short_node_id(verify_key_bytes: bytes) -> str:
|
| 82 |
+
"""First 8 bytes base32, grouped: 'ed25519:XXXX-XXXX-XXXX-XXXX'."""
|
| 83 |
+
|
| 84 |
+
def full_node_id(verify_key_bytes: bytes) -> str:
|
| 85 |
+
"""All 32 bytes, base64-url no pad, prefixed: 'ed25519:<b64>'."""
|
| 86 |
+
|
| 87 |
+
def parse_node_id(node_id: str) -> bytes:
|
| 88 |
+
"""Decode the full form back to 32 bytes. Short form raises ValueError."""
|
| 89 |
+
|
| 90 |
+
def verify_key_from_full(node_id_full: str) -> nacl.signing.VerifyKey:
|
| 91 |
+
"""Convenience: parse and wrap."""
|
| 92 |
+
|
| 93 |
+
# --- canonical JSON ---
|
| 94 |
+
|
| 95 |
+
def canonical_json(obj: Any) -> bytes:
|
| 96 |
+
"""Sorted keys, no whitespace, no trailing zeros on numbers, UTF-8.
|
| 97 |
+
Used everywhere a signature is computed."""
|
| 98 |
+
|
| 99 |
+
# --- signing / verification ---
|
| 100 |
+
|
| 101 |
+
def sign_payload(payload: dict, kp: KeyPair) -> dict:
|
| 102 |
+
"""Add a 'signature' field over canonical_json(payload \\ {signature})."""
|
| 103 |
+
|
| 104 |
+
def verify_payload(payload: dict, vk: nacl.signing.VerifyKey) -> bool:
|
| 105 |
+
"""True iff the 'signature' field validates over canonical_json(payload \\ {signature})."""
|
| 106 |
+
|
| 107 |
+
def verify_payload_with_node_id(payload: dict, expected_node_id_full: str) -> bool:
|
| 108 |
+
"""Convenience: derive verify key from node_id and call verify_payload."""
|
| 109 |
+
|
| 110 |
+
# --- TLS ---
|
| 111 |
+
|
| 112 |
+
def generate_self_signed_cert(kp: KeyPair, host: str = "0.0.0.0") -> tuple[bytes, bytes]:
|
| 113 |
+
"""Generate an X.509 self-signed cert+key for TLS. The CN includes the short node id.
|
| 114 |
+
Returns (cert_pem, key_pem). The cert is rotated when the device key changes (i.e. never)."""
|
| 115 |
+
|
| 116 |
+
class IdentityError(Exception):
|
| 117 |
+
"""code in {'keys_missing','keys_invalid','keys_permissions','bad_node_id','sign_failed','verify_failed'}"""
|
| 118 |
+
code: str
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
### 3.2 `manifest.py`
|
| 122 |
+
|
| 123 |
+
```python
|
| 124 |
+
# hearthnet/identity/manifest.py
|
| 125 |
+
from dataclasses import dataclass, field
|
| 126 |
+
from typing import Any
|
| 127 |
+
from datetime import datetime, timezone, timedelta
|
| 128 |
+
|
| 129 |
+
# ---- types ----
|
| 130 |
+
|
| 131 |
+
@dataclass(frozen=True)
|
| 132 |
+
class Endpoint:
|
| 133 |
+
transport: str # "https"
|
| 134 |
+
host: str
|
| 135 |
+
port: int
|
| 136 |
+
|
| 137 |
+
@dataclass(frozen=True)
|
| 138 |
+
class HardwareSpec:
|
| 139 |
+
gpu: str | None
|
| 140 |
+
vram_gb: float
|
| 141 |
+
ram_gb: float
|
| 142 |
+
cpu_cores: int
|
| 143 |
+
disk_free_gb: float
|
| 144 |
+
|
| 145 |
+
@dataclass(frozen=True)
|
| 146 |
+
class CapabilitySpec:
|
| 147 |
+
"""As it appears INSIDE a node manifest (subset of the full descriptor in M03)."""
|
| 148 |
+
name: str
|
| 149 |
+
version: str # "1.0"
|
| 150 |
+
stability: str # "stable" | "beta" | "experimental"
|
| 151 |
+
schema_hash: str # "blake3:..."
|
| 152 |
+
params: dict[str, Any]
|
| 153 |
+
max_concurrent: int
|
| 154 |
+
|
| 155 |
+
@dataclass(frozen=True)
|
| 156 |
+
class NodeManifest:
|
| 157 |
+
version: int # always 1 for this contract version
|
| 158 |
+
contract_version: str # "1.0"
|
| 159 |
+
node_id: str # full form
|
| 160 |
+
display_name: str
|
| 161 |
+
community_id: str
|
| 162 |
+
profile: str # "anchor" | "hearth" | "spark" | "bridge"
|
| 163 |
+
endpoints: list[Endpoint]
|
| 164 |
+
hardware: HardwareSpec
|
| 165 |
+
capabilities: list[CapabilitySpec]
|
| 166 |
+
uptime_seconds: int
|
| 167 |
+
load: dict[str, Any]
|
| 168 |
+
issued_at: str # RFC 3339
|
| 169 |
+
expires_at: str # RFC 3339
|
| 170 |
+
signature: str
|
| 171 |
+
|
| 172 |
+
def as_dict(self) -> dict: ...
|
| 173 |
+
def is_expired(self, now: datetime | None = None) -> bool: ...
|
| 174 |
+
|
| 175 |
+
# ---- node manifest builder & verifier ----
|
| 176 |
+
|
| 177 |
+
def build_node_manifest(
|
| 178 |
+
kp: KeyPair,
|
| 179 |
+
community_id: str,
|
| 180 |
+
display_name: str,
|
| 181 |
+
profile: str,
|
| 182 |
+
endpoints: list[Endpoint],
|
| 183 |
+
hardware: HardwareSpec,
|
| 184 |
+
capabilities: list[CapabilitySpec],
|
| 185 |
+
uptime_seconds: int,
|
| 186 |
+
load: dict[str, Any],
|
| 187 |
+
) -> NodeManifest:
|
| 188 |
+
"""Build and sign a fresh node manifest. issued_at = now,
|
| 189 |
+
expires_at = now + MANIFEST_TTL_SECONDS."""
|
| 190 |
+
|
| 191 |
+
def parse_node_manifest(blob: bytes | dict) -> NodeManifest:
|
| 192 |
+
"""Parse JSON bytes or dict into a typed NodeManifest. Does NOT verify signature."""
|
| 193 |
+
|
| 194 |
+
def verify_node_manifest(manifest: NodeManifest, *, now: datetime | None = None) -> None:
|
| 195 |
+
"""Verify signature + expiry. Raises IdentityError on failure:
|
| 196 |
+
'invalid_signature' | 'expired' | 'bad_manifest' (malformed structure)."""
|
| 197 |
+
|
| 198 |
+
# ---- community manifest ----
|
| 199 |
+
|
| 200 |
+
@dataclass(frozen=True)
|
| 201 |
+
class CommunityPolicy:
|
| 202 |
+
min_signatures_to_invite: int
|
| 203 |
+
min_signatures_to_demote: int
|
| 204 |
+
min_signatures_to_revoke: int
|
| 205 |
+
capability_token_ttl_seconds: int
|
| 206 |
+
federation_enabled: bool
|
| 207 |
+
default_member_can_invite: bool
|
| 208 |
+
|
| 209 |
+
@dataclass(frozen=True)
|
| 210 |
+
class CommunityMember:
|
| 211 |
+
node_id: str
|
| 212 |
+
level: str # "anchor" | "trusted" | "member"
|
| 213 |
+
added_at: str
|
| 214 |
+
added_by: str
|
| 215 |
+
|
| 216 |
+
@dataclass(frozen=True)
|
| 217 |
+
class RevokedEntry:
|
| 218 |
+
node_id: str
|
| 219 |
+
revoked_at: str
|
| 220 |
+
|
| 221 |
+
@dataclass(frozen=True)
|
| 222 |
+
class CommunityManifest:
|
| 223 |
+
version: int
|
| 224 |
+
community_id: str
|
| 225 |
+
name: str
|
| 226 |
+
root_key: str
|
| 227 |
+
created_at: str
|
| 228 |
+
lamport_at_creation: int
|
| 229 |
+
policy: CommunityPolicy
|
| 230 |
+
members: list[CommunityMember]
|
| 231 |
+
revoked: list[RevokedEntry]
|
| 232 |
+
head_lamport: int
|
| 233 |
+
signature: str
|
| 234 |
+
|
| 235 |
+
def is_member(self, node_id: str) -> bool: ...
|
| 236 |
+
def level_of(self, node_id: str) -> str | None: ...
|
| 237 |
+
def is_revoked(self, node_id: str) -> bool: ...
|
| 238 |
+
|
| 239 |
+
def build_community_manifest(
|
| 240 |
+
root_kp: KeyPair,
|
| 241 |
+
name: str,
|
| 242 |
+
policy: CommunityPolicy,
|
| 243 |
+
) -> CommunityManifest:
|
| 244 |
+
"""Build and sign the genesis community manifest. lamport_at_creation = 0, head_lamport = 0,
|
| 245 |
+
members = [root as 'anchor'], revoked = []."""
|
| 246 |
+
|
| 247 |
+
def regenerate_community_manifest_from_state(
|
| 248 |
+
materialised_state: dict,
|
| 249 |
+
signing_kp: KeyPair,
|
| 250 |
+
) -> CommunityManifest:
|
| 251 |
+
"""Given a materialised view from the event log, produce a fresh signed manifest.
|
| 252 |
+
Signing key may be any anchor's key, not just root."""
|
| 253 |
+
|
| 254 |
+
def parse_community_manifest(blob: bytes | dict) -> CommunityManifest: ...
|
| 255 |
+
def verify_community_manifest(cm: CommunityManifest) -> None: ...
|
| 256 |
+
```
|
| 257 |
+
|
| 258 |
+
### 3.3 `tokens.py` (Phase 2 — stubs only in MVP)
|
| 259 |
+
|
| 260 |
+
```python
|
| 261 |
+
# hearthnet/identity/tokens.py
|
| 262 |
+
@dataclass(frozen=True)
|
| 263 |
+
class CapabilityToken:
|
| 264 |
+
issuer: str # NodeID issuing the token
|
| 265 |
+
subject: str # NodeID who may use it
|
| 266 |
+
capability: str # "rag.query"
|
| 267 |
+
issued_at: str
|
| 268 |
+
expires_at: str
|
| 269 |
+
nonce: str # ULID
|
| 270 |
+
signature: str
|
| 271 |
+
|
| 272 |
+
def issue_token(...) -> CapabilityToken: ... # Phase 2
|
| 273 |
+
def verify_token(token: CapabilityToken, expected_issuer: str) -> None: ... # Phase 2
|
| 274 |
+
```
|
| 275 |
+
|
| 276 |
+
---
|
| 277 |
+
|
| 278 |
+
## 4. On-disk format
|
| 279 |
+
|
| 280 |
+
```
|
| 281 |
+
<DATA>/keys/
|
| 282 |
+
├── device.ed25519 # raw 32-byte signing key, no header, 0600
|
| 283 |
+
└── device.pub # raw 32-byte verify key, 0644
|
| 284 |
+
```
|
| 285 |
+
|
| 286 |
+
Single-key design (no rotation, no chain). Rationale: a community member's key is their identity; rotation creates a new identity. If a key is compromised, the right action is to revoke that NodeID and the user re-onboards with a new key.
|
| 287 |
+
|
| 288 |
+
---
|
| 289 |
+
|
| 290 |
+
## 5. Behaviour
|
| 291 |
+
|
| 292 |
+
### 5.1 First-run flow
|
| 293 |
+
|
| 294 |
+
```
|
| 295 |
+
load_or_generate(keys_dir) →
|
| 296 |
+
if device.ed25519 exists:
|
| 297 |
+
read, validate permissions, parse → KeyPair
|
| 298 |
+
else:
|
| 299 |
+
generate → save → KeyPair
|
| 300 |
+
```
|
| 301 |
+
|
| 302 |
+
### 5.2 Manifest TTL and republish
|
| 303 |
+
|
| 304 |
+
The node orchestrator (`node.py`) republishes manifest every `MANIFEST_REPUBLISH_INTERVAL_SECONDS` (20s). Receivers consider any manifest with `expires_at < now` as `expired` and drop it.
|
| 305 |
+
|
| 306 |
+
### 5.3 Verification budget
|
| 307 |
+
|
| 308 |
+
Verification is cheap (Ed25519 is fast), but at scale (1000 events/sec) it can add up. Verifying nodes cache `(payload_hash, sig, result)` for 60 seconds to avoid re-verifying identical bytes.
|
| 309 |
+
|
| 310 |
+
### 5.4 Pinned keys
|
| 311 |
+
|
| 312 |
+
The first time we see a NodeID's verify key, we pin it in memory. A subsequent manifest with the same NodeID but a different verify key is rejected with `invalid_signature` and logged at `warning`. This is TOFU (trust-on-first-use) and is acceptable inside a community where invites carry the verify key.
|
| 313 |
+
|
| 314 |
+
---
|
| 315 |
+
|
| 316 |
+
## 6. Errors
|
| 317 |
+
|
| 318 |
+
All errors raise `IdentityError` with a `code` in: `keys_missing`, `keys_invalid`, `keys_permissions`, `bad_node_id`, `sign_failed`, `verify_failed`, `bad_manifest`, `expired`, `invalid_signature`.
|
| 319 |
+
|
| 320 |
+
These codes map cleanly onto the wire error codes in [CONTRACT §9](../CAPABILITY_CONTRACT.md).
|
| 321 |
+
|
| 322 |
+
---
|
| 323 |
+
|
| 324 |
+
## 7. Configuration
|
| 325 |
+
|
| 326 |
+
From [X04 §3](../cross-cutting/X04-config.md):
|
| 327 |
+
|
| 328 |
+
```python
|
| 329 |
+
config.identity.keys_dir # where keys live
|
| 330 |
+
config.identity.auto_generate # default True
|
| 331 |
+
```
|
| 332 |
+
|
| 333 |
+
---
|
| 334 |
+
|
| 335 |
+
## 8. Tests
|
| 336 |
+
|
| 337 |
+
### Unit
|
| 338 |
+
- `test_keypair_roundtrip` — save / load returns equal keys
|
| 339 |
+
- `test_short_node_id_format` — matches `ed25519:[A-Z2-7]{4}(-[A-Z2-7]{4}){3}`
|
| 340 |
+
- `test_canonical_json_is_stable` — sort + no-whitespace is deterministic
|
| 341 |
+
- `test_canonical_json_numbers` — `1.0`, `1.00`, `1` all canonicalise to `1`
|
| 342 |
+
- `test_sign_verify_roundtrip` — random payloads
|
| 343 |
+
- `test_verify_fails_on_modified_field` — flip one byte, verify returns False
|
| 344 |
+
- `test_node_manifest_expiry` — exact TTL boundary behaviour
|
| 345 |
+
- `test_community_manifest_genesis` — root key is sole anchor
|
| 346 |
+
- `test_tls_cert_includes_short_node_id` — CN match
|
| 347 |
+
|
| 348 |
+
### Integration
|
| 349 |
+
- `test_manifest_round_trip_over_http` — server emits, client receives, parses, verifies
|
| 350 |
+
- `test_tofu_key_pinning` — second different key for same NodeID is rejected
|
| 351 |
+
|
| 352 |
+
---
|
| 353 |
+
|
| 354 |
+
## 9. Cross-references
|
| 355 |
+
|
| 356 |
+
| What | Where |
|
| 357 |
+
|------|-------|
|
| 358 |
+
| Wire signing rules | [CONTRACT §1.3, §5.1](../CAPABILITY_CONTRACT.md) |
|
| 359 |
+
| Canonical JSON | [CONTRACT §1.2](../CAPABILITY_CONTRACT.md) |
|
| 360 |
+
| Node manifest schema | [CONTRACT §6.1](../CAPABILITY_CONTRACT.md) |
|
| 361 |
+
| Community manifest schema | [CONTRACT §6.2](../CAPABILITY_CONTRACT.md) |
|
| 362 |
+
| Used by event signing | [X02 §3](../cross-cutting/X02-events.md) |
|
| 363 |
+
| Used by transport TLS pinning | [X01 §4](../cross-cutting/X01-transport.md) |
|
| 364 |
+
| Used by bus per-request verification | [M03 §5.6](M03-bus.md) |
|
| 365 |
+
|
| 366 |
+
---
|
| 367 |
+
|
| 368 |
+
## 10. Open questions
|
| 369 |
+
|
| 370 |
+
1. **Mobile clients store keys in `localStorage`** — not great. Phase 2: WebCrypto + IndexedDB.
|
| 371 |
+
2. **Key rotation** — currently impossible without identity change. Phase 3 may add a "linked-keys" event.
|
| 372 |
+
3. **HSM / Yubikey support** — out of scope; possible Phase 3.
|
docs/M02-discovery.md
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# M02 — Discovery
|
| 2 |
+
|
| 3 |
+
**Spec version:** v1.0
|
| 4 |
+
**Depends on:** M01 (identity), X04 (config), X03 (observability), X01 (transport, for the manifest fetch URL), `python-zeroconf`
|
| 5 |
+
**Depended on by:** M03 (bus, for peer enumeration), M09 (emergency mode increases discovery cadence)
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 1. Responsibility
|
| 10 |
+
|
| 11 |
+
Find peers on the local network. Maintain a live in-memory registry of known peers with their manifests, last-seen timestamps, and latencies. Republish our own presence.
|
| 12 |
+
|
| 13 |
+
Out of scope:
|
| 14 |
+
- DHT (Phase 2)
|
| 15 |
+
- LoRa beacons (Phase 3)
|
| 16 |
+
- Internet relay (Phase 2)
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## 2. File layout
|
| 21 |
+
|
| 22 |
+
```
|
| 23 |
+
hearthnet/discovery/
|
| 24 |
+
├── __init__.py
|
| 25 |
+
├── mdns.py # zeroconf-based service browser + announcer
|
| 26 |
+
├── udp.py # UDP broadcast announcer + listener
|
| 27 |
+
├── peers.py # PeerRegistry: in-memory state
|
| 28 |
+
└── relay.py # Phase 2 stub
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
---
|
| 32 |
+
|
| 33 |
+
## 3. Public API
|
| 34 |
+
|
| 35 |
+
### 3.1 `peers.py`
|
| 36 |
+
|
| 37 |
+
```python
|
| 38 |
+
# hearthnet/discovery/peers.py
|
| 39 |
+
from dataclasses import dataclass
|
| 40 |
+
|
| 41 |
+
@dataclass
|
| 42 |
+
class PeerRecord:
|
| 43 |
+
node_id: str # short form
|
| 44 |
+
node_id_full: str
|
| 45 |
+
display_name: str
|
| 46 |
+
community_id: str
|
| 47 |
+
profile: str
|
| 48 |
+
endpoints: list[Endpoint]
|
| 49 |
+
manifest: NodeManifest | None # None until fetched
|
| 50 |
+
last_seen: float # monotonic time
|
| 51 |
+
rtt_ms: float | None # measured by health probe
|
| 52 |
+
source: str # "mdns" | "udp" | "relay"
|
| 53 |
+
|
| 54 |
+
class PeerRegistry:
|
| 55 |
+
"""In-memory map of NodeID → PeerRecord. Thread-safe via asyncio.Lock."""
|
| 56 |
+
|
| 57 |
+
def __init__(self, our_node_id_full: str, community_id: str):
|
| 58 |
+
...
|
| 59 |
+
|
| 60 |
+
def upsert(self, record: PeerRecord) -> bool:
|
| 61 |
+
"""Add or update; returns True if new peer."""
|
| 62 |
+
|
| 63 |
+
def remove(self, node_id_full: str) -> bool: ...
|
| 64 |
+
|
| 65 |
+
def get(self, node_id_full: str) -> PeerRecord | None: ...
|
| 66 |
+
|
| 67 |
+
def all(self) -> list[PeerRecord]: ...
|
| 68 |
+
|
| 69 |
+
def for_community(self, community_id: str) -> list[PeerRecord]: ...
|
| 70 |
+
|
| 71 |
+
def prune_stale(self, max_age_seconds: int = 90) -> int:
|
| 72 |
+
"""Remove peers not seen recently. Returns count removed."""
|
| 73 |
+
|
| 74 |
+
# subscribers (called when peer added / removed / updated):
|
| 75 |
+
def subscribe(self) -> AsyncIterator[PeerEvent]: ...
|
| 76 |
+
|
| 77 |
+
@dataclass(frozen=True)
|
| 78 |
+
class PeerEvent:
|
| 79 |
+
kind: str # "added" | "removed" | "updated"
|
| 80 |
+
peer: PeerRecord
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
### 3.2 `mdns.py`
|
| 84 |
+
|
| 85 |
+
```python
|
| 86 |
+
# hearthnet/discovery/mdns.py
|
| 87 |
+
class MdnsAnnouncer:
|
| 88 |
+
"""Publishes our own service via mDNS."""
|
| 89 |
+
def __init__(
|
| 90 |
+
self,
|
| 91 |
+
kp: KeyPair,
|
| 92 |
+
node_id_short: str,
|
| 93 |
+
display_name: str,
|
| 94 |
+
community_id_short: str,
|
| 95 |
+
profile: str,
|
| 96 |
+
port: int,
|
| 97 |
+
capabilities_names: list[str],
|
| 98 |
+
manifest_url: str,
|
| 99 |
+
):
|
| 100 |
+
...
|
| 101 |
+
async def start(self) -> None: ...
|
| 102 |
+
async def stop(self) -> None: ...
|
| 103 |
+
def update(self, *, capabilities_names: list[str] | None = None) -> None:
|
| 104 |
+
"""Refresh TXT records (e.g. when capabilities change)."""
|
| 105 |
+
|
| 106 |
+
class MdnsBrowser:
|
| 107 |
+
"""Listens for other nodes via mDNS, populates the registry."""
|
| 108 |
+
def __init__(self, registry: PeerRegistry, our_community_id: str):
|
| 109 |
+
...
|
| 110 |
+
async def start(self) -> None: ...
|
| 111 |
+
async def stop(self) -> None: ...
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
### 3.3 Service definition
|
| 115 |
+
|
| 116 |
+
- Service type: `_hearthnet._tcp.local.`
|
| 117 |
+
- Instance name: `<display_name>-<short_node_id_4chars>`
|
| 118 |
+
- Port: from manifest's first endpoint
|
| 119 |
+
- TXT records:
|
| 120 |
+
- `v=1`
|
| 121 |
+
- `node=<short_node_id>`
|
| 122 |
+
- `community=<short_community_id>`
|
| 123 |
+
- `profile=<anchor|hearth|spark|bridge>`
|
| 124 |
+
- `caps=<comma-separated cap names>` (max 200 bytes; truncate if needed)
|
| 125 |
+
- `manifest_url=https://<host>:<port>/manifest`
|
| 126 |
+
- `contract_version=1.0`
|
| 127 |
+
|
| 128 |
+
### 3.4 `udp.py`
|
| 129 |
+
|
| 130 |
+
```python
|
| 131 |
+
# hearthnet/discovery/udp.py
|
| 132 |
+
class UdpAnnouncer:
|
| 133 |
+
"""Periodic UDP multicast of node presence."""
|
| 134 |
+
def __init__(
|
| 135 |
+
self,
|
| 136 |
+
kp: KeyPair,
|
| 137 |
+
registry: PeerRegistry,
|
| 138 |
+
node_id_short: str,
|
| 139 |
+
community_id_short: str,
|
| 140 |
+
port: int,
|
| 141 |
+
capabilities_names: list[str],
|
| 142 |
+
multicast_group: str = "239.255.42.42",
|
| 143 |
+
multicast_port: int = 42424,
|
| 144 |
+
):
|
| 145 |
+
...
|
| 146 |
+
async def run(self) -> None:
|
| 147 |
+
"""Loop: emit announcement every DISCOVERY_UDP_INTERVAL_SECONDS.
|
| 148 |
+
Active interval when fewer than 2 peers; stable interval otherwise."""
|
| 149 |
+
|
| 150 |
+
class UdpListener:
|
| 151 |
+
"""Receives multicast announcements, populates registry."""
|
| 152 |
+
def __init__(self, registry: PeerRegistry, our_community_id: str): ...
|
| 153 |
+
async def run(self) -> None: ...
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
### 3.5 UDP payload
|
| 157 |
+
|
| 158 |
+
```json
|
| 159 |
+
{"v":1,"node":"7H4G-Y9KL","community":"NIED-...","port":7080,"caps":["llm.chat","rag.query"]}
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
Max 1KB. No signature on the announce itself (we'll re-fetch & verify the full manifest from `manifest_url`).
|
| 163 |
+
|
| 164 |
+
---
|
| 165 |
+
|
| 166 |
+
## 4. Behaviour
|
| 167 |
+
|
| 168 |
+
### 4.1 First contact flow
|
| 169 |
+
|
| 170 |
+
```
|
| 171 |
+
mDNS or UDP discovers a peer at <host:port> for community X (matches ours)
|
| 172 |
+
↓
|
| 173 |
+
PeerRegistry.upsert(stub PeerRecord with manifest=None)
|
| 174 |
+
↓
|
| 175 |
+
asyncio task: HTTP GET https://<host>:<port>/manifest (via X01 client)
|
| 176 |
+
↓
|
| 177 |
+
parse + verify_node_manifest (M01)
|
| 178 |
+
↓
|
| 179 |
+
if community matches AND author is a member (community manifest): keep
|
| 180 |
+
otherwise: remove
|
| 181 |
+
↓
|
| 182 |
+
PeerEvent("added") emitted
|
| 183 |
+
```
|
| 184 |
+
|
| 185 |
+
### 4.2 Refresh
|
| 186 |
+
|
| 187 |
+
- mDNS TXT updates trigger re-fetch of `/manifest`
|
| 188 |
+
- Every 30 seconds, we attempt to refresh peers whose manifests are within 10 seconds of expiry
|
| 189 |
+
- Peers whose manifests expired and could not be refetched are pruned after 90 seconds
|
| 190 |
+
|
| 191 |
+
### 4.3 Mode behaviour
|
| 192 |
+
|
| 193 |
+
When [M09](M09-emergency.md) reports offline:
|
| 194 |
+
|
| 195 |
+
- `UdpAnnouncer` switches to fast interval
|
| 196 |
+
- `MdnsAnnouncer` doesn't change (already low-overhead)
|
| 197 |
+
- Stale peer pruning becomes more aggressive (30s instead of 90s) — we want fresh data quickly
|
| 198 |
+
|
| 199 |
+
### 4.4 Multi-interface handling
|
| 200 |
+
|
| 201 |
+
- mDNS uses `zeroconf` defaults (all interfaces)
|
| 202 |
+
- UDP listener binds to `INADDR_ANY` on the multicast group; SO_REUSEPORT so multiple processes can coexist on the same host
|
| 203 |
+
|
| 204 |
+
### 4.5 Privacy
|
| 205 |
+
|
| 206 |
+
mDNS announces the short NodeID, profile, and a list of capability names. This is visible to any device on the LAN. We accept this — it is the price of zero-config.
|
| 207 |
+
|
| 208 |
+
Devices NOT in our community still see our presence but cannot make calls (rejected at the bus signature check).
|
| 209 |
+
|
| 210 |
+
---
|
| 211 |
+
|
| 212 |
+
## 5. Errors
|
| 213 |
+
|
| 214 |
+
`DiscoveryError` codes:
|
| 215 |
+
|
| 216 |
+
- `socket_in_use` — UDP port already bound
|
| 217 |
+
- `mdns_unavailable` — zeroconf fails to start (Linux without avahi, etc.)
|
| 218 |
+
- `manifest_fetch_failed` — HTTP error fetching `/manifest`
|
| 219 |
+
- `manifest_invalid` — propagated from M01 verification
|
| 220 |
+
|
| 221 |
+
Errors are logged but not fatal; the node continues with whichever discovery transport works.
|
| 222 |
+
|
| 223 |
+
---
|
| 224 |
+
|
| 225 |
+
## 6. Configuration
|
| 226 |
+
|
| 227 |
+
From [X04](X04-config.md):
|
| 228 |
+
|
| 229 |
+
```python
|
| 230 |
+
config.discovery.mdns_enabled
|
| 231 |
+
config.discovery.udp_enabled
|
| 232 |
+
config.discovery.udp_multicast_group
|
| 233 |
+
config.discovery.udp_port
|
| 234 |
+
config.discovery.relay_urls # Phase 2
|
| 235 |
+
```
|
| 236 |
+
|
| 237 |
+
Constants: `DISCOVERY_UDP_INTERVAL_SECONDS`.
|
| 238 |
+
|
| 239 |
+
---
|
| 240 |
+
|
| 241 |
+
## 7. Tests
|
| 242 |
+
|
| 243 |
+
### Unit
|
| 244 |
+
- `test_peer_registry_upsert_returns_true_first_time`
|
| 245 |
+
- `test_peer_registry_prune_stale`
|
| 246 |
+
- `test_udp_payload_under_1kb`
|
| 247 |
+
- `test_mdns_txt_records_parse`
|
| 248 |
+
|
| 249 |
+
### Integration
|
| 250 |
+
- `test_two_nodes_find_each_other_via_mdns` (in-process zeroconf)
|
| 251 |
+
- `test_udp_fallback_when_mdns_disabled`
|
| 252 |
+
- `test_foreign_community_peer_filtered_out`
|
| 253 |
+
|
| 254 |
+
---
|
| 255 |
+
|
| 256 |
+
## 8. Cross-references
|
| 257 |
+
|
| 258 |
+
| What | Where |
|
| 259 |
+
|------|-------|
|
| 260 |
+
| Manifest fetch + verify | [M01 §3.2](M01-identity.md) |
|
| 261 |
+
| Service definition | [CONTRACT §6.1](../CAPABILITY_CONTRACT.md) (manifest schema) |
|
| 262 |
+
| Bus consumes peer events | [M03 §5.2](M03-bus.md) |
|
| 263 |
+
| Emergency mode influence | [M09 §5](M09-emergency.md) |
|
| 264 |
+
| Phase 2 internet relay | this module's `relay.py` (stub) |
|
docs/M03-bus.md
ADDED
|
@@ -0,0 +1,632 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# M03 — Capability Bus
|
| 2 |
+
|
| 3 |
+
**Spec version:** v1.0
|
| 4 |
+
**Depends on:** M01 (identity), X01 (transport, for outbound calls), X03 (observability), X04 (config), M02 (discovery, for peer events)
|
| 5 |
+
**Depended on by:** every service (M04, M05, M06, M07, M10, M11), M08 (UI calls capabilities via bus), M09 (registers nothing but reads peer state via bus), M12 (CLI inspects bus state)
|
| 6 |
+
|
| 7 |
+
This is **the** integration point. Re-read [00-OVERVIEW §2](../00-OVERVIEW.md) before changing this file.
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## 1. Responsibility
|
| 12 |
+
|
| 13 |
+
- Maintain a registry of **capabilities**: local (offered by us) and remote (offered by other nodes)
|
| 14 |
+
- Validate every call against a JSON schema declared by the capability descriptor
|
| 15 |
+
- Route every call to the best provider via a scoring algorithm
|
| 16 |
+
- Track per-(node, capability) health and quarantine misbehaving providers
|
| 17 |
+
- Enforce per-capability concurrency limits and per-peer rate budgets (delegating to X01)
|
| 18 |
+
- Emit structured trace events for every call (via X03)
|
| 19 |
+
- Provide sticky routing for multi-turn capabilities (chat)
|
| 20 |
+
|
| 21 |
+
What the bus does **not** do:
|
| 22 |
+
- It does not move bytes (X01 does)
|
| 23 |
+
- It does not persist anything (X02 does, for events)
|
| 24 |
+
- It does not know any service's internals (services register themselves)
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
## 2. File layout
|
| 29 |
+
|
| 30 |
+
```
|
| 31 |
+
hearthnet/bus/
|
| 32 |
+
├── __init__.py # exports: CapabilityBus, CapabilityDescriptor, CapabilityEntry
|
| 33 |
+
├── capability.py # dataclasses: CapabilityDescriptor, CapabilityEntry, RouteRequest
|
| 34 |
+
├── registry.py # Registry: local + remote capability index
|
| 35 |
+
├── router.py # Router: scoring algorithm
|
| 36 |
+
├── health.py # HealthTracker: rolling-window per-(node, cap) stats
|
| 37 |
+
├── schema.py # SchemaValidator + schema_hash computation
|
| 38 |
+
└── trace.py # TraceHook: emit standardised trace events
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
---
|
| 42 |
+
|
| 43 |
+
## 3. Public API
|
| 44 |
+
|
| 45 |
+
### 3.1 `capability.py`
|
| 46 |
+
|
| 47 |
+
```python
|
| 48 |
+
# hearthnet/bus/capability.py
|
| 49 |
+
from dataclasses import dataclass, field
|
| 50 |
+
from typing import Any, AsyncIterator, Callable, Awaitable
|
| 51 |
+
|
| 52 |
+
CapabilityName = str # "llm.chat"
|
| 53 |
+
Version = tuple[int, int] # (1, 0)
|
| 54 |
+
|
| 55 |
+
# --- Descriptor: the full capability spec, used at registration ---
|
| 56 |
+
|
| 57 |
+
@dataclass(frozen=True)
|
| 58 |
+
class CapabilityDescriptor:
|
| 59 |
+
"""The complete contract for one capability offered by one service.
|
| 60 |
+
Registered exactly once per (service, capability)."""
|
| 61 |
+
name: CapabilityName
|
| 62 |
+
version: Version # (1, 0)
|
| 63 |
+
stability: str # "stable" | "beta" | "experimental"
|
| 64 |
+
request_schema: dict # JSON Schema
|
| 65 |
+
response_schema: dict | None # JSON Schema; None if pure stream
|
| 66 |
+
stream_schema: dict | None # JSON Schema for stream frames; None if non-streaming
|
| 67 |
+
params: dict[str, Any] # capability-instance params (e.g. {"model": "..."})
|
| 68 |
+
max_concurrent: int # per-node limit
|
| 69 |
+
trust_required: str # "member" | "trusted" | "anchor" | "self"
|
| 70 |
+
timeout_seconds: int # default deadline for this capability
|
| 71 |
+
idempotent: bool
|
| 72 |
+
|
| 73 |
+
@property
|
| 74 |
+
def version_str(self) -> str:
|
| 75 |
+
"""E.g. '1.0'"""
|
| 76 |
+
|
| 77 |
+
def schema_hash(self) -> str:
|
| 78 |
+
"""BLAKE3 over canonical-JSON of {name, version, request_schema, response_schema, stream_schema}.
|
| 79 |
+
Prefixed 'blake3:'. See CONTRACT §11."""
|
| 80 |
+
|
| 81 |
+
# --- Entry: the bus's record of one capability instance ---
|
| 82 |
+
|
| 83 |
+
@dataclass
|
| 84 |
+
class CapabilityEntry:
|
| 85 |
+
"""The bus's view of one (node, capability) pair.
|
| 86 |
+
For local capabilities, node_id == self.node_id and handler != None."""
|
| 87 |
+
node_id: str # full form
|
| 88 |
+
descriptor: CapabilityDescriptor
|
| 89 |
+
is_local: bool
|
| 90 |
+
handler: Callable | None # only for local entries
|
| 91 |
+
endpoint: Endpoint | None # only for remote entries
|
| 92 |
+
in_flight: int = 0
|
| 93 |
+
last_seen: float = 0.0 # monotonic seconds
|
| 94 |
+
p50_latency_ms: float = 0.0
|
| 95 |
+
p99_latency_ms: float = 0.0
|
| 96 |
+
success_rate: float = 1.0 # rolling HEALTH_WINDOW_CALLS
|
| 97 |
+
quarantined_until: float = 0.0
|
| 98 |
+
sticky_sessions: set[str] = field(default_factory=set)
|
| 99 |
+
|
| 100 |
+
# --- Internal request envelope ---
|
| 101 |
+
|
| 102 |
+
@dataclass(frozen=True)
|
| 103 |
+
class RouteRequest:
|
| 104 |
+
"""An inbound call after signature/membership check.
|
| 105 |
+
Constructed by the transport layer before being handed to the bus."""
|
| 106 |
+
capability: CapabilityName
|
| 107 |
+
version_req: Version # the *requested* major+minimum minor
|
| 108 |
+
body: dict # the JSON body (params + input)
|
| 109 |
+
caller: str # full NodeID
|
| 110 |
+
trace_id: str
|
| 111 |
+
session_id: str | None # for sticky routing
|
| 112 |
+
deadline_ms: int # absolute monotonic ms
|
| 113 |
+
stream: bool # caller-requested
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
### 3.2 `registry.py`
|
| 117 |
+
|
| 118 |
+
```python
|
| 119 |
+
# hearthnet/bus/registry.py
|
| 120 |
+
class Registry:
|
| 121 |
+
"""Holds CapabilityEntry instances keyed by (node_id, name, version).
|
| 122 |
+
Thread-safe via asyncio.Lock."""
|
| 123 |
+
|
| 124 |
+
def __init__(self, our_node_id: str): ...
|
| 125 |
+
|
| 126 |
+
# -- local registration --
|
| 127 |
+
|
| 128 |
+
def register_local(
|
| 129 |
+
self,
|
| 130 |
+
descriptor: CapabilityDescriptor,
|
| 131 |
+
handler: Callable[[RouteRequest], Awaitable[dict] | AsyncIterator[dict]],
|
| 132 |
+
) -> None:
|
| 133 |
+
"""Register a capability our process offers. Idempotent if descriptor unchanged.
|
| 134 |
+
Raises BusError('schema_invalid') if request/response schemas don't validate against JSON Schema meta-schema.
|
| 135 |
+
Raises BusError('namespace_violation') if name is outside this service's declared prefix."""
|
| 136 |
+
|
| 137 |
+
def deregister_local(self, name: CapabilityName, version: Version) -> None: ...
|
| 138 |
+
|
| 139 |
+
# -- remote sync (driven by M02 peer events + the periodic manifest fetch) --
|
| 140 |
+
|
| 141 |
+
def update_from_peer_manifest(self, peer: PeerRecord, manifest: NodeManifest) -> Diff:
|
| 142 |
+
"""Compare offered capabilities to existing entries; add/remove as needed.
|
| 143 |
+
Returns a Diff describing changes."""
|
| 144 |
+
|
| 145 |
+
def remove_peer(self, node_id: str) -> int:
|
| 146 |
+
"""Remove all entries for a peer. Returns count removed."""
|
| 147 |
+
|
| 148 |
+
# -- queries --
|
| 149 |
+
|
| 150 |
+
def find(
|
| 151 |
+
self,
|
| 152 |
+
name: CapabilityName,
|
| 153 |
+
version_req: Version,
|
| 154 |
+
params_filter: Callable[[dict], bool] | None = None,
|
| 155 |
+
) -> list[CapabilityEntry]:
|
| 156 |
+
"""Return all entries matching name and version compatibility.
|
| 157 |
+
See CONTRACT §2.1 for compatibility rules."""
|
| 158 |
+
|
| 159 |
+
def entry(self, node_id: str, name: CapabilityName, version: Version) -> CapabilityEntry | None: ...
|
| 160 |
+
|
| 161 |
+
def all_local(self) -> list[CapabilityEntry]: ...
|
| 162 |
+
def all(self) -> list[CapabilityEntry]: ...
|
| 163 |
+
|
| 164 |
+
# -- subscriptions --
|
| 165 |
+
|
| 166 |
+
def subscribe(self) -> AsyncIterator[RegistryEvent]:
|
| 167 |
+
"""Yield 'added' / 'removed' / 'updated' events. Used by UI topology viz."""
|
| 168 |
+
|
| 169 |
+
@dataclass(frozen=True)
|
| 170 |
+
class Diff:
|
| 171 |
+
added: list[CapabilityEntry]
|
| 172 |
+
removed: list[CapabilityEntry]
|
| 173 |
+
updated: list[CapabilityEntry]
|
| 174 |
+
|
| 175 |
+
@dataclass(frozen=True)
|
| 176 |
+
class RegistryEvent:
|
| 177 |
+
kind: str # "added" | "removed" | "updated"
|
| 178 |
+
entry: CapabilityEntry
|
| 179 |
+
```
|
| 180 |
+
|
| 181 |
+
### 3.3 `health.py`
|
| 182 |
+
|
| 183 |
+
```python
|
| 184 |
+
# hearthnet/bus/health.py
|
| 185 |
+
class HealthTracker:
|
| 186 |
+
"""Rolling-window health stats per (node_id, capability_name, version).
|
| 187 |
+
Constant memory: O(nodes × capabilities)."""
|
| 188 |
+
|
| 189 |
+
def __init__(self, window: int = HEALTH_WINDOW_CALLS): ...
|
| 190 |
+
|
| 191 |
+
def record(self, entry: CapabilityEntry, *, success: bool, latency_ms: float) -> None:
|
| 192 |
+
"""Append a sample; recompute p50/p99/success_rate; update entry in place.
|
| 193 |
+
Quarantines entry if success_rate drops below HEALTH_QUARANTINE_THRESHOLD."""
|
| 194 |
+
|
| 195 |
+
def is_quarantined(self, entry: CapabilityEntry) -> bool: ...
|
| 196 |
+
|
| 197 |
+
def reset(self, entry: CapabilityEntry) -> None:
|
| 198 |
+
"""Clear stats — used after quarantine timeout."""
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
Internal: each entry holds a fixed-size ring buffer of `(success, latency_ms)` samples. Old samples drop off as new ones arrive.
|
| 202 |
+
|
| 203 |
+
### 3.4 `schema.py`
|
| 204 |
+
|
| 205 |
+
```python
|
| 206 |
+
# hearthnet/bus/schema.py
|
| 207 |
+
class SchemaValidator:
|
| 208 |
+
"""JSON Schema validation, with caching."""
|
| 209 |
+
|
| 210 |
+
def __init__(self): ...
|
| 211 |
+
|
| 212 |
+
def validate_request(self, descriptor: CapabilityDescriptor, body: dict) -> None:
|
| 213 |
+
"""Raises BusError('schema_mismatch') with expected schema_hash if invalid."""
|
| 214 |
+
|
| 215 |
+
def validate_response(self, descriptor: CapabilityDescriptor, body: dict) -> None: ...
|
| 216 |
+
def validate_stream_frame(self, descriptor: CapabilityDescriptor, frame: dict) -> None: ...
|
| 217 |
+
|
| 218 |
+
def compute_schema_hash(descriptor_partial: dict) -> str:
|
| 219 |
+
"""BLAKE3 over canonical-JSON. See CONTRACT §11.
|
| 220 |
+
Argument shape:
|
| 221 |
+
{
|
| 222 |
+
'name': ..., 'version': ...,
|
| 223 |
+
'request_schema': {...},
|
| 224 |
+
'response_schema': {...} or None,
|
| 225 |
+
'stream_schema': {...} or None,
|
| 226 |
+
}
|
| 227 |
+
Returns 'blake3:<hex>'."""
|
| 228 |
+
```
|
| 229 |
+
|
| 230 |
+
### 3.5 `router.py`
|
| 231 |
+
|
| 232 |
+
```python
|
| 233 |
+
# hearthnet/bus/router.py
|
| 234 |
+
class Router:
|
| 235 |
+
"""Selects the best CapabilityEntry for a request."""
|
| 236 |
+
|
| 237 |
+
def __init__(self, registry: Registry, config: BusConfig, our_node_id: str): ...
|
| 238 |
+
|
| 239 |
+
def route(self, req: RouteRequest) -> CapabilityEntry | None:
|
| 240 |
+
"""Return the chosen entry, or None if no candidate is viable.
|
| 241 |
+
Candidates must:
|
| 242 |
+
- match name and version (CONTRACT §2.1)
|
| 243 |
+
- pass params_compatible() (capability-specific, see §5.5)
|
| 244 |
+
- not be quarantined
|
| 245 |
+
- have in_flight < max_concurrent
|
| 246 |
+
- have last_seen within freshness window (60s)
|
| 247 |
+
Scoring: see §5.4."""
|
| 248 |
+
|
| 249 |
+
def route_sticky(self, req: RouteRequest) -> CapabilityEntry | None:
|
| 250 |
+
"""If req.session_id is bound to an entry, return that entry if still viable.
|
| 251 |
+
Otherwise fall back to route() and bind."""
|
| 252 |
+
|
| 253 |
+
def release_session(self, session_id: str) -> None: ...
|
| 254 |
+
```
|
| 255 |
+
|
| 256 |
+
### 3.6 `trace.py`
|
| 257 |
+
|
| 258 |
+
```python
|
| 259 |
+
# hearthnet/bus/trace.py
|
| 260 |
+
@dataclass(frozen=True)
|
| 261 |
+
class CallTraceEvent:
|
| 262 |
+
ts: str
|
| 263 |
+
trace_id: str
|
| 264 |
+
capability: CapabilityName
|
| 265 |
+
version: str
|
| 266 |
+
from_node: str
|
| 267 |
+
to_node: str
|
| 268 |
+
is_local: bool
|
| 269 |
+
result: str # "ok" | error_code
|
| 270 |
+
ms: float
|
| 271 |
+
tokens_in: int | None # llm.*-specific
|
| 272 |
+
tokens_out: int | None
|
| 273 |
+
bytes_in: int
|
| 274 |
+
bytes_out: int
|
| 275 |
+
|
| 276 |
+
class TraceHook:
|
| 277 |
+
"""Emits trace events to the ring buffer (X03) and Prometheus metrics."""
|
| 278 |
+
|
| 279 |
+
def __init__(self): ...
|
| 280 |
+
|
| 281 |
+
def on_call_start(self, req: RouteRequest, entry: CapabilityEntry) -> None: ...
|
| 282 |
+
def on_call_end(
|
| 283 |
+
self,
|
| 284 |
+
req: RouteRequest,
|
| 285 |
+
entry: CapabilityEntry,
|
| 286 |
+
*,
|
| 287 |
+
result: str,
|
| 288 |
+
latency_ms: float,
|
| 289 |
+
bytes_in: int,
|
| 290 |
+
bytes_out: int,
|
| 291 |
+
tokens_in: int | None = None,
|
| 292 |
+
tokens_out: int | None = None,
|
| 293 |
+
) -> None: ...
|
| 294 |
+
```
|
| 295 |
+
|
| 296 |
+
### 3.7 `CapabilityBus` (the facade)
|
| 297 |
+
|
| 298 |
+
```python
|
| 299 |
+
# hearthnet/bus/__init__.py
|
| 300 |
+
class CapabilityBus:
|
| 301 |
+
"""The integration point. Services register with this; transport dispatches to it."""
|
| 302 |
+
|
| 303 |
+
def __init__(
|
| 304 |
+
self,
|
| 305 |
+
node_id_full: str,
|
| 306 |
+
community_id: str,
|
| 307 |
+
config: BusConfig,
|
| 308 |
+
transport_client: HttpClient,
|
| 309 |
+
community_manifest_provider: Callable[[], CommunityManifest],
|
| 310 |
+
):
|
| 311 |
+
self.registry = Registry(our_node_id=node_id_full)
|
| 312 |
+
self.health = HealthTracker()
|
| 313 |
+
self.schema = SchemaValidator()
|
| 314 |
+
self.router = Router(self.registry, config, our_node_id=node_id_full)
|
| 315 |
+
self.trace = TraceHook()
|
| 316 |
+
self._client = transport_client
|
| 317 |
+
...
|
| 318 |
+
|
| 319 |
+
# --- service-side: registration ---
|
| 320 |
+
|
| 321 |
+
def register_service(self, service: 'Service') -> None:
|
| 322 |
+
"""Calls service.capabilities() and registers each with the local handler."""
|
| 323 |
+
|
| 324 |
+
def register_capability(
|
| 325 |
+
self,
|
| 326 |
+
descriptor: CapabilityDescriptor,
|
| 327 |
+
handler: Callable[[RouteRequest], Awaitable[dict] | AsyncIterator[dict]],
|
| 328 |
+
) -> None:
|
| 329 |
+
"""Lower-level alternative when a module has no Service class."""
|
| 330 |
+
|
| 331 |
+
# --- transport-side: dispatch ---
|
| 332 |
+
|
| 333 |
+
async def handle_call(self, req: RouteRequest) -> dict | AsyncIterator[dict]:
|
| 334 |
+
"""Called by the X01 server after auth.
|
| 335 |
+
Decides local vs remote, validates schema, runs the handler or makes a remote call,
|
| 336 |
+
records trace + health, returns the payload (or yields frames)."""
|
| 337 |
+
|
| 338 |
+
# --- caller-side: outbound capability invocation ---
|
| 339 |
+
|
| 340 |
+
async def call(
|
| 341 |
+
self,
|
| 342 |
+
capability: CapabilityName,
|
| 343 |
+
version_req: Version,
|
| 344 |
+
body: dict,
|
| 345 |
+
*,
|
| 346 |
+
session_id: str | None = None,
|
| 347 |
+
timeout_seconds: float | None = None,
|
| 348 |
+
) -> dict:
|
| 349 |
+
"""Used by services that need to invoke other capabilities (e.g. rag.query calling embed.text).
|
| 350 |
+
Goes through the router. If chosen entry is local, runs handler directly.
|
| 351 |
+
If remote, uses X01 client. Records trace + health."""
|
| 352 |
+
|
| 353 |
+
async def stream(
|
| 354 |
+
self,
|
| 355 |
+
capability: CapabilityName,
|
| 356 |
+
version_req: Version,
|
| 357 |
+
body: dict,
|
| 358 |
+
*,
|
| 359 |
+
session_id: str | None = None,
|
| 360 |
+
) -> AsyncIterator[Frame]:
|
| 361 |
+
"""Streaming version of call()."""
|
| 362 |
+
|
| 363 |
+
# --- peer integration ---
|
| 364 |
+
|
| 365 |
+
def on_peer_added(self, peer: PeerRecord) -> None: ...
|
| 366 |
+
def on_peer_updated(self, peer: PeerRecord) -> None: ...
|
| 367 |
+
def on_peer_removed(self, node_id: str) -> None: ...
|
| 368 |
+
|
| 369 |
+
# --- introspection (used by UI / CLI) ---
|
| 370 |
+
|
| 371 |
+
def topology_snapshot(self) -> 'TopologySnapshot': ...
|
| 372 |
+
def recent_traces(self, n: int = 50) -> list[CallTraceEvent]: ...
|
| 373 |
+
def stats(self) -> dict: ...
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
@dataclass(frozen=True)
|
| 377 |
+
class TopologySnapshot:
|
| 378 |
+
our_node_id: str
|
| 379 |
+
peers: list[PeerRecord]
|
| 380 |
+
capabilities_local: list[CapabilityEntry]
|
| 381 |
+
capabilities_remote: list[CapabilityEntry]
|
| 382 |
+
in_flight_total: int
|
| 383 |
+
|
| 384 |
+
class BusError(Exception):
|
| 385 |
+
"""code in {schema_invalid, namespace_violation, schema_mismatch, not_found, capacity_exceeded,
|
| 386 |
+
quarantined, partition, timeout, internal_error}"""
|
| 387 |
+
code: str
|
| 388 |
+
```
|
| 389 |
+
|
| 390 |
+
---
|
| 391 |
+
|
| 392 |
+
## 4. The Service protocol (consumed from `services/base.py`)
|
| 393 |
+
|
| 394 |
+
```python
|
| 395 |
+
# hearthnet/services/base.py
|
| 396 |
+
class Service(Protocol):
|
| 397 |
+
"""Implemented by every L4 service module."""
|
| 398 |
+
name: str # "llm" | "rag" | "marketplace" | ...
|
| 399 |
+
version: str # service version, separate from capability version
|
| 400 |
+
|
| 401 |
+
def capabilities(self) -> list[tuple[CapabilityDescriptor, Callable]]:
|
| 402 |
+
"""Return (descriptor, handler) pairs for each capability this service offers.
|
| 403 |
+
Handlers signature: (RouteRequest) -> Awaitable[dict] | AsyncIterator[dict]"""
|
| 404 |
+
|
| 405 |
+
async def start(self) -> None:
|
| 406 |
+
"""Warm up backends, open DBs, etc."""
|
| 407 |
+
|
| 408 |
+
async def stop(self) -> None:
|
| 409 |
+
"""Release resources."""
|
| 410 |
+
|
| 411 |
+
def health(self) -> dict:
|
| 412 |
+
"""Implementation-specific health blob (used by /health)."""
|
| 413 |
+
```
|
| 414 |
+
|
| 415 |
+
---
|
| 416 |
+
|
| 417 |
+
## 5. Behaviour
|
| 418 |
+
|
| 419 |
+
### 5.1 Bootstrap
|
| 420 |
+
|
| 421 |
+
```
|
| 422 |
+
node.py creates CapabilityBus
|
| 423 |
+
↓
|
| 424 |
+
node.py instantiates services, calls bus.register_service(s) for each
|
| 425 |
+
↓
|
| 426 |
+
each service.start() runs (model loaded, DB opened, etc.)
|
| 427 |
+
↓
|
| 428 |
+
node.py wires bus.on_peer_added <- PeerRegistry.subscribe()
|
| 429 |
+
↓
|
| 430 |
+
node.py wires X01 server handler to bus.handle_call
|
| 431 |
+
↓
|
| 432 |
+
bus is live
|
| 433 |
+
```
|
| 434 |
+
|
| 435 |
+
### 5.2 Peer event handling
|
| 436 |
+
|
| 437 |
+
When [M02](M02-discovery.md) emits `PeerEvent("added")` with a verified manifest:
|
| 438 |
+
|
| 439 |
+
```
|
| 440 |
+
bus.on_peer_added(peer)
|
| 441 |
+
→ registry.update_from_peer_manifest(peer, peer.manifest) → Diff
|
| 442 |
+
→ for each added entry: emit RegistryEvent('added')
|
| 443 |
+
→ for each removed entry: emit RegistryEvent('removed')
|
| 444 |
+
```
|
| 445 |
+
|
| 446 |
+
### 5.3 Inbound call lifecycle (`handle_call`)
|
| 447 |
+
|
| 448 |
+
```
|
| 449 |
+
1. transport receives, verifies signature, builds RouteRequest
|
| 450 |
+
2. bus.handle_call(req):
|
| 451 |
+
a. session_id present?
|
| 452 |
+
yes → entry = router.route_sticky(req)
|
| 453 |
+
no → entry = router.route(req)
|
| 454 |
+
b. entry is None → raise BusError('not_found')
|
| 455 |
+
c. enforce trust_required (compare against caller's level in community manifest)
|
| 456 |
+
d. validate_request(entry.descriptor, req.body)
|
| 457 |
+
e. entry.in_flight >= max_concurrent → raise BusError('capacity_exceeded') with retry_after
|
| 458 |
+
f. entry.in_flight += 1
|
| 459 |
+
g. trace.on_call_start(req, entry)
|
| 460 |
+
h. if entry.is_local:
|
| 461 |
+
result = await entry.handler(req)
|
| 462 |
+
else:
|
| 463 |
+
result = await self._client.call(entry.endpoint, ...)
|
| 464 |
+
i. validate_response(entry.descriptor, result) (if non-stream)
|
| 465 |
+
j. trace.on_call_end(...), health.record(...)
|
| 466 |
+
k. entry.in_flight -= 1
|
| 467 |
+
l. return result
|
| 468 |
+
```
|
| 469 |
+
|
| 470 |
+
For streaming: same flow but `result` is an async iterator; validation is per-frame; final telemetry recorded on `done` or `error`.
|
| 471 |
+
|
| 472 |
+
### 5.4 Routing algorithm (Router.route)
|
| 473 |
+
|
| 474 |
+
```python
|
| 475 |
+
def route(req):
|
| 476 |
+
candidates = registry.find(req.capability, req.version_req)
|
| 477 |
+
candidates = [
|
| 478 |
+
e for e in candidates
|
| 479 |
+
if not health.is_quarantined(e)
|
| 480 |
+
and e.in_flight < e.descriptor.max_concurrent
|
| 481 |
+
and (e.is_local or e.last_seen > monotonic() - 60)
|
| 482 |
+
and params_compatible(e.descriptor.params, req.body.get("params", {}))
|
| 483 |
+
]
|
| 484 |
+
if not candidates:
|
| 485 |
+
return None
|
| 486 |
+
if config.prefer_local:
|
| 487 |
+
local = [e for e in candidates if e.is_local]
|
| 488 |
+
if local and local[0].in_flight / max(local[0].descriptor.max_concurrent, 1) < config.local_load_threshold:
|
| 489 |
+
return local[0]
|
| 490 |
+
return min(candidates, key=score)
|
| 491 |
+
|
| 492 |
+
def score(e):
|
| 493 |
+
latency = e.p50_latency_ms if e.p50_latency_ms > 0 else 500.0 # unknown → assume 500ms
|
| 494 |
+
load = e.in_flight / max(e.descriptor.max_concurrent, 1)
|
| 495 |
+
reliability_penalty = (1 - e.success_rate) * 1000
|
| 496 |
+
locality_bonus = -50 if e.is_local else 0
|
| 497 |
+
return latency * (1 + load) + reliability_penalty + locality_bonus
|
| 498 |
+
```
|
| 499 |
+
|
| 500 |
+
### 5.5 `params_compatible` (per-capability)
|
| 501 |
+
|
| 502 |
+
The bus alone cannot know which `params` matter for compatibility (model name for `llm.chat`, corpus for `rag.query`). Services register a `params_compatible` predicate alongside their descriptor:
|
| 503 |
+
|
| 504 |
+
```python
|
| 505 |
+
# In service's capabilities() return value, tuple is actually:
|
| 506 |
+
# (descriptor, handler, params_compatible)
|
| 507 |
+
# where params_compatible: Callable[[dict, dict], bool]
|
| 508 |
+
# args: (offered_params, requested_params) → True if requested can be served
|
| 509 |
+
```
|
| 510 |
+
|
| 511 |
+
Default predicate: `lambda offered, requested: True` (any-matches-any). LLM service overrides to check model/quant/ctx; RAG overrides to check corpus name; etc. Documented per-service in M04 / M05 / etc.
|
| 512 |
+
|
| 513 |
+
### 5.6 Sticky routing
|
| 514 |
+
|
| 515 |
+
For multi-turn capabilities (`llm.chat` continuations, future `chat.thread`):
|
| 516 |
+
|
| 517 |
+
- Caller passes `session_id`
|
| 518 |
+
- First call: router picks an entry, records `entry.sticky_sessions.add(session_id)`
|
| 519 |
+
- Subsequent calls with same `session_id`: router returns same entry if still viable
|
| 520 |
+
- TTL: 10 minutes idle (driven by a background sweeper)
|
| 521 |
+
- On entry removal (peer left): session unbinds; next call gets a new entry; caller MAY observe context loss (capability handler returns `bad_request` if context required)
|
| 522 |
+
|
| 523 |
+
### 5.7 Quarantine
|
| 524 |
+
|
| 525 |
+
```
|
| 526 |
+
health.record(entry, success, latency):
|
| 527 |
+
append to ring buffer
|
| 528 |
+
recompute success_rate over last HEALTH_WINDOW_CALLS samples (or fewer if young)
|
| 529 |
+
if success_rate < HEALTH_QUARANTINE_THRESHOLD:
|
| 530 |
+
entry.quarantined_until = monotonic() + HEALTH_QUARANTINE_SECONDS
|
| 531 |
+
log.warning("quarantined", capability, node_id, success_rate)
|
| 532 |
+
metrics.counter("hearthnet_quarantines_total").inc()
|
| 533 |
+
|
| 534 |
+
router.route considers quarantined_until <= monotonic() before including.
|
| 535 |
+
```
|
| 536 |
+
|
| 537 |
+
After quarantine timeout, the next call is a "probe": if it succeeds, history resets; if it fails, immediate re-quarantine.
|
| 538 |
+
|
| 539 |
+
### 5.8 Outbound `call` from a service
|
| 540 |
+
|
| 541 |
+
```
|
| 542 |
+
service code: await bus.call("embed.text", (1,0), {"params": {...}, "input": {...}})
|
| 543 |
+
→ router.route(...) → entry
|
| 544 |
+
→ if entry.is_local: dispatch to handler directly (no HTTP roundtrip)
|
| 545 |
+
→ else: client.call(entry.endpoint, ...)
|
| 546 |
+
```
|
| 547 |
+
|
| 548 |
+
Local short-circuit is the reason a service in the same process can use the bus without paying network cost.
|
| 549 |
+
|
| 550 |
+
---
|
| 551 |
+
|
| 552 |
+
## 6. Errors (BusError code mapping)
|
| 553 |
+
|
| 554 |
+
| BusError code | Wire `ErrorCode` | HTTP status |
|
| 555 |
+
|---------------|-----------------|-------------|
|
| 556 |
+
| `schema_invalid` | (raised at registration; never on wire) | — |
|
| 557 |
+
| `namespace_violation` | (raised at registration) | — |
|
| 558 |
+
| `schema_mismatch` | `schema_mismatch` | 400 |
|
| 559 |
+
| `not_found` | `not_found` | 404 |
|
| 560 |
+
| `capacity_exceeded` | `capacity_exceeded` | 429 |
|
| 561 |
+
| `quarantined` | `partition` | 503 |
|
| 562 |
+
| `partition` | `partition` | 503 |
|
| 563 |
+
| `timeout` | `timeout` | 408 |
|
| 564 |
+
| `internal_error` | `internal_error` | 500 |
|
| 565 |
+
|
| 566 |
+
Plus auth-level errors raised before the bus sees the request: `unauthorized`, `revoked`, `invalid_signature`, `expired`, `rate_limited` — see [X01 §3.3](../cross-cutting/X01-transport.md).
|
| 567 |
+
|
| 568 |
+
---
|
| 569 |
+
|
| 570 |
+
## 7. Configuration
|
| 571 |
+
|
| 572 |
+
From [X04](../cross-cutting/X04-config.md):
|
| 573 |
+
|
| 574 |
+
```python
|
| 575 |
+
config.bus.prefer_local
|
| 576 |
+
config.bus.local_load_threshold
|
| 577 |
+
```
|
| 578 |
+
|
| 579 |
+
Constants used: `HEALTH_WINDOW_CALLS`, `HEALTH_QUARANTINE_THRESHOLD`, `HEALTH_QUARANTINE_SECONDS`.
|
| 580 |
+
|
| 581 |
+
---
|
| 582 |
+
|
| 583 |
+
## 8. Tests
|
| 584 |
+
|
| 585 |
+
### Unit
|
| 586 |
+
|
| 587 |
+
- `test_register_local_validates_descriptor`
|
| 588 |
+
- `test_register_local_namespace_violation_raises`
|
| 589 |
+
- `test_schema_hash_stable_across_runs`
|
| 590 |
+
- `test_schema_hash_changes_on_schema_change`
|
| 591 |
+
- `test_router_prefers_local_when_underloaded`
|
| 592 |
+
- `test_router_prefers_remote_when_local_overloaded`
|
| 593 |
+
- `test_router_skips_quarantined`
|
| 594 |
+
- `test_router_breaks_ties_by_latency_then_reliability`
|
| 595 |
+
- `test_health_quarantines_at_threshold`
|
| 596 |
+
- `test_health_resets_after_quarantine_window`
|
| 597 |
+
- `test_params_compatible_predicate_invoked`
|
| 598 |
+
- `test_sticky_session_binds_then_unbinds_on_peer_removal`
|
| 599 |
+
- `test_in_flight_decrements_on_handler_exception`
|
| 600 |
+
|
| 601 |
+
### Integration
|
| 602 |
+
|
| 603 |
+
- `test_two_nodes_route_to_each_other` — two in-process buses, one registers `llm.chat`, the other calls
|
| 604 |
+
- `test_three_nodes_load_balance` — three providers, 100 calls, distribution within 30% of even
|
| 605 |
+
- `test_quarantine_after_chaos` — fault inject one provider, observe quarantine then recovery
|
| 606 |
+
- `test_streaming_call_records_full_trace`
|
| 607 |
+
- `test_sticky_routing_preserves_session_across_calls`
|
| 608 |
+
|
| 609 |
+
---
|
| 610 |
+
|
| 611 |
+
## 9. Cross-references
|
| 612 |
+
|
| 613 |
+
| What | Where |
|
| 614 |
+
|------|-------|
|
| 615 |
+
| Capability descriptor concept | [CONTRACT §2, §11](../CAPABILITY_CONTRACT.md) |
|
| 616 |
+
| Schema hash computation | [CONTRACT §11](../CAPABILITY_CONTRACT.md) |
|
| 617 |
+
| Version compatibility | [CONTRACT §2.1](../CAPABILITY_CONTRACT.md) |
|
| 618 |
+
| Wire-level error codes | [CONTRACT §9](../CAPABILITY_CONTRACT.md) |
|
| 619 |
+
| Transport dispatch | [X01 §3.3](../cross-cutting/X01-transport.md) |
|
| 620 |
+
| Peer registry | [M02 §3.1](M02-discovery.md) |
|
| 621 |
+
| Trace ring buffer | [X03 §5](../cross-cutting/X03-observability.md) |
|
| 622 |
+
| Service protocol consumers | [M04](M04-llm.md), [M05](M05-rag.md), [M06](M06-marketplace.md), [M07](M07-file-blobs.md), [M10](M10-chat.md), [M11](M11-embedding.md) |
|
| 623 |
+
| UI consumes topology_snapshot | [M08 §3.2](M08-ui.md) |
|
| 624 |
+
|
| 625 |
+
---
|
| 626 |
+
|
| 627 |
+
## 10. Open questions
|
| 628 |
+
|
| 629 |
+
1. **Per-capability params predicate registration** — currently a third tuple element. Cleaner alternative: store as method on a `CapabilitySpec` subclass. Decide before M04 lands.
|
| 630 |
+
2. **Sticky session TTL** — fixed 10 minutes? Or per-capability declared? MVP: fixed. Phase 2: declared.
|
| 631 |
+
3. **Load balancing fairness** — current scorer is greedy. Should we add a small random jitter to avoid herd-on-fastest? MVP: no. If we see herd in tests, add ε-noise.
|
| 632 |
+
4. **Schema cache invalidation** — currently keyed by `schema_hash`. Implicit invalidation on hash change. Should be sufficient.
|
docs/M04-llm.md
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# M04 — LLM Service
|
| 2 |
+
|
| 3 |
+
**Spec version:** v1.0
|
| 4 |
+
**Depends on:** M03 (bus), X04 (config), X03 (observability), backend libs (llama-cpp-python, ollama HTTP, httpx for HTTP backends)
|
| 5 |
+
**Depended on by:** M05 (RAG uses llm.complete internally), M08 (UI passes user queries through llm.chat)
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 1. Responsibility
|
| 10 |
+
|
| 11 |
+
Provide `llm.chat@1.0` and `llm.complete@1.0`. Wrap multiple inference backends (llama.cpp, Ollama, LM Studio, HF Inference API, Anthropic API, OpenAI-compatible HTTP). Register one capability instance per (backend, model, quant) tuple so the bus can see them as separate routable providers.
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## 2. File layout
|
| 16 |
+
|
| 17 |
+
```
|
| 18 |
+
hearthnet/services/llm/
|
| 19 |
+
├── __init__.py
|
| 20 |
+
├── service.py # LlmService
|
| 21 |
+
├── tokenizers.py # rough token counting per family
|
| 22 |
+
└── backends/
|
| 23 |
+
├── __init__.py
|
| 24 |
+
├── base.py # LlmBackend Protocol
|
| 25 |
+
├── llama_cpp.py # llama-cpp-python in-process
|
| 26 |
+
├── ollama.py # Ollama HTTP at http://localhost:11434
|
| 27 |
+
├── lmstudio.py # LM Studio HTTP (OpenAI-compatible)
|
| 28 |
+
├── hf_api.py # HuggingFace Inference API
|
| 29 |
+
└── anthropic_api.py # Anthropic Messages API
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
---
|
| 33 |
+
|
| 34 |
+
## 3. Public API
|
| 35 |
+
|
| 36 |
+
### 3.1 `backends/base.py`
|
| 37 |
+
|
| 38 |
+
```python
|
| 39 |
+
# hearthnet/services/llm/backends/base.py
|
| 40 |
+
from dataclasses import dataclass
|
| 41 |
+
from typing import AsyncIterator, Protocol
|
| 42 |
+
|
| 43 |
+
@dataclass(frozen=True)
|
| 44 |
+
class Token:
|
| 45 |
+
text: str
|
| 46 |
+
logprob: float | None
|
| 47 |
+
stop: bool
|
| 48 |
+
|
| 49 |
+
@dataclass(frozen=True)
|
| 50 |
+
class ChatResult:
|
| 51 |
+
text: str
|
| 52 |
+
tokens_in: int
|
| 53 |
+
tokens_out: int
|
| 54 |
+
stop_reason: str # "end" | "max_tokens" | "stop_sequence" | "cancelled"
|
| 55 |
+
ms: int
|
| 56 |
+
|
| 57 |
+
@dataclass(frozen=True)
|
| 58 |
+
class BackendModel:
|
| 59 |
+
"""One model an LlmBackend can serve."""
|
| 60 |
+
name: str # "qwen2.5-7b-instruct"
|
| 61 |
+
quant: str # "q4_k_m", "q8_0", "fp16", "api"
|
| 62 |
+
ctx_max: int # 8192
|
| 63 |
+
modalities: list[str] # ["text"] or ["text", "vision"]
|
| 64 |
+
requires_internet: bool # API backends → True; local → False
|
| 65 |
+
|
| 66 |
+
class LlmBackend(Protocol):
|
| 67 |
+
"""Abstract backend. Implementations cover one provider."""
|
| 68 |
+
|
| 69 |
+
name: str # "llama_cpp" | "ollama" | ...
|
| 70 |
+
models: list[BackendModel]
|
| 71 |
+
|
| 72 |
+
async def warm(self, model: str) -> None: ...
|
| 73 |
+
async def close(self) -> None: ...
|
| 74 |
+
|
| 75 |
+
async def chat(
|
| 76 |
+
self,
|
| 77 |
+
*,
|
| 78 |
+
model: str,
|
| 79 |
+
messages: list[dict],
|
| 80 |
+
max_tokens: int = 1024,
|
| 81 |
+
temperature: float = 0.7,
|
| 82 |
+
top_p: float = 0.95,
|
| 83 |
+
stop: list[str] | None = None,
|
| 84 |
+
seed: int | None = None,
|
| 85 |
+
stream: bool = True,
|
| 86 |
+
) -> AsyncIterator[Token]:
|
| 87 |
+
"""Yields Tokens. The final Token has stop=True."""
|
| 88 |
+
|
| 89 |
+
async def complete(
|
| 90 |
+
self,
|
| 91 |
+
*,
|
| 92 |
+
model: str,
|
| 93 |
+
prompt: str,
|
| 94 |
+
max_tokens: int = 256,
|
| 95 |
+
temperature: float = 0.7,
|
| 96 |
+
top_p: float = 0.95,
|
| 97 |
+
stop: list[str] | None = None,
|
| 98 |
+
seed: int | None = None,
|
| 99 |
+
stream: bool = True,
|
| 100 |
+
) -> AsyncIterator[Token]: ...
|
| 101 |
+
|
| 102 |
+
def count_tokens(self, model: str, text: str) -> int:
|
| 103 |
+
"""Approximate token count; uses a per-model tokenizer if available."""
|
| 104 |
+
|
| 105 |
+
def max_concurrent(self, model: str) -> int:
|
| 106 |
+
"""Backend-specific concurrency limit. Used in capability descriptor."""
|
| 107 |
+
|
| 108 |
+
def health(self) -> dict: ...
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
### 3.2 Concrete backends
|
| 112 |
+
|
| 113 |
+
```python
|
| 114 |
+
# hearthnet/services/llm/backends/llama_cpp.py
|
| 115 |
+
class LlamaCppBackend(LlmBackend):
|
| 116 |
+
"""In-process llama-cpp-python. Loads one model at a time per instance.
|
| 117 |
+
Multiple LlamaCppBackend instances may coexist if VRAM allows."""
|
| 118 |
+
|
| 119 |
+
def __init__(self, model_path: Path, model_meta: BackendModel, gpu_layers: int = -1):
|
| 120 |
+
...
|
| 121 |
+
|
| 122 |
+
# hearthnet/services/llm/backends/ollama.py
|
| 123 |
+
class OllamaBackend(LlmBackend):
|
| 124 |
+
"""HTTP-based Ollama at http://localhost:11434 (or remote)."""
|
| 125 |
+
|
| 126 |
+
def __init__(self, base_url: str = "http://localhost:11434", models: list[str] | None = None):
|
| 127 |
+
"""If models is None, discover via GET /api/tags."""
|
| 128 |
+
|
| 129 |
+
# hearthnet/services/llm/backends/lmstudio.py
|
| 130 |
+
class LmStudioBackend(LlmBackend):
|
| 131 |
+
"""OpenAI-compatible HTTP at http://host:1234.
|
| 132 |
+
Used in Christof's home setup at 192.168.188.25:1234."""
|
| 133 |
+
|
| 134 |
+
def __init__(self, base_url: str, default_model: str): ...
|
| 135 |
+
|
| 136 |
+
# hearthnet/services/llm/backends/hf_api.py
|
| 137 |
+
class HfApiBackend(LlmBackend):
|
| 138 |
+
"""HuggingFace Inference API. Requires HF_TOKEN env var (declared in config.llm.backends[].api_key_env)."""
|
| 139 |
+
|
| 140 |
+
def __init__(self, model: str, token_env: str = "HF_TOKEN"): ...
|
| 141 |
+
|
| 142 |
+
# hearthnet/services/llm/backends/anthropic_api.py
|
| 143 |
+
class AnthropicApiBackend(LlmBackend):
|
| 144 |
+
"""Anthropic Messages API. Phase 1.5; useful when internet up."""
|
| 145 |
+
|
| 146 |
+
def __init__(self, model: str = "claude-sonnet-4-6", token_env: str = "ANTHROPIC_API_KEY"): ...
|
| 147 |
+
```
|
| 148 |
+
|
| 149 |
+
### 3.3 `tokenizers.py`
|
| 150 |
+
|
| 151 |
+
```python
|
| 152 |
+
# hearthnet/services/llm/tokenizers.py
|
| 153 |
+
def count_tokens_approx(model_family: str, text: str) -> int:
|
| 154 |
+
"""Fast heuristic: chars / 3.5 for Latin scripts, /2 for CJK.
|
| 155 |
+
Used when no real tokenizer is available."""
|
| 156 |
+
|
| 157 |
+
def model_family(model_name: str) -> str:
|
| 158 |
+
"""'qwen2.5-7b-instruct' → 'qwen', 'llama-3-8b' → 'llama', etc."""
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
### 3.4 `service.py`
|
| 162 |
+
|
| 163 |
+
```python
|
| 164 |
+
# hearthnet/services/llm/service.py
|
| 165 |
+
class LlmService:
|
| 166 |
+
name = "llm"
|
| 167 |
+
version = "1.0"
|
| 168 |
+
|
| 169 |
+
def __init__(self, config: LlmConfig):
|
| 170 |
+
self._backends: list[LlmBackend] = self._build_backends(config)
|
| 171 |
+
|
| 172 |
+
def _build_backends(self, config: LlmConfig) -> list[LlmBackend]:
|
| 173 |
+
"""Instantiate each declared backend; skip backends that fail to initialise (with warning)."""
|
| 174 |
+
|
| 175 |
+
def capabilities(self) -> list[tuple[CapabilityDescriptor, Callable, ParamsPredicate]]:
|
| 176 |
+
"""Emits one (descriptor, handler, predicate) per (backend, model, capability_kind) combo:
|
| 177 |
+
- For each backend × each model: one llm.chat entry and one llm.complete entry.
|
| 178 |
+
- Each descriptor's params include model, quant, ctx, backend."""
|
| 179 |
+
|
| 180 |
+
async def start(self) -> None:
|
| 181 |
+
"""Warm one backend (the first listed) to avoid cold-start lag on first call."""
|
| 182 |
+
|
| 183 |
+
async def stop(self) -> None: ...
|
| 184 |
+
def health(self) -> dict: ...
|
| 185 |
+
|
| 186 |
+
# --- handlers ---
|
| 187 |
+
|
| 188 |
+
async def handle_chat(self, req: RouteRequest) -> AsyncIterator[dict]:
|
| 189 |
+
"""Streams SSE frames per CONTRACT §4.1.
|
| 190 |
+
Picks the backend from req.body['params']['model'] (matched at routing).
|
| 191 |
+
Maps backend Token → SSE 'token' frames; emits 'done' with meta."""
|
| 192 |
+
|
| 193 |
+
async def handle_complete(self, req: RouteRequest) -> AsyncIterator[dict]:
|
| 194 |
+
"""Same shape as chat but for CONTRACT §4.2."""
|
| 195 |
+
```
|
| 196 |
+
|
| 197 |
+
### 3.5 Capability descriptors
|
| 198 |
+
|
| 199 |
+
For each `(backend, model)` pair, the service registers:
|
| 200 |
+
|
| 201 |
+
```python
|
| 202 |
+
# llm.chat instance
|
| 203 |
+
CapabilityDescriptor(
|
| 204 |
+
name="llm.chat",
|
| 205 |
+
version=(1, 0),
|
| 206 |
+
stability="stable",
|
| 207 |
+
request_schema={...}, # CONTRACT §4.1 schema
|
| 208 |
+
response_schema={...}, # for non-stream fallback
|
| 209 |
+
stream_schema={
|
| 210 |
+
"oneOf": [
|
| 211 |
+
{"type": "object", "required": ["text"], "properties": {"text": {"type": "string"}, "logprob": {"type": ["number", "null"]}}},
|
| 212 |
+
{"type": "object", "required": ["tokens_out", "stop_reason", "ms"]} # done frame
|
| 213 |
+
]
|
| 214 |
+
},
|
| 215 |
+
params={
|
| 216 |
+
"model": "<model.name>",
|
| 217 |
+
"quant": "<model.quant>",
|
| 218 |
+
"ctx": model.ctx_max,
|
| 219 |
+
"backend": "<backend.name>",
|
| 220 |
+
"modalities": model.modalities,
|
| 221 |
+
},
|
| 222 |
+
max_concurrent=backend.max_concurrent(model.name),
|
| 223 |
+
trust_required="member",
|
| 224 |
+
timeout_seconds=LLM_GENERATION_DEFAULT_TIMEOUT_SECONDS,
|
| 225 |
+
idempotent=False,
|
| 226 |
+
)
|
| 227 |
+
```
|
| 228 |
+
|
| 229 |
+
### 3.6 `params_compatible` predicate
|
| 230 |
+
|
| 231 |
+
```python
|
| 232 |
+
def params_compatible(offered: dict, requested: dict) -> bool:
|
| 233 |
+
# Required match: model.
|
| 234 |
+
# Optional match: ctx (caller's must be <= offered).
|
| 235 |
+
if requested.get("model") != offered.get("model"):
|
| 236 |
+
return False
|
| 237 |
+
if "ctx" in requested and requested["ctx"] > offered["ctx"]:
|
| 238 |
+
return False
|
| 239 |
+
return True
|
| 240 |
+
```
|
| 241 |
+
|
| 242 |
+
---
|
| 243 |
+
|
| 244 |
+
## 4. Behaviour
|
| 245 |
+
|
| 246 |
+
### 4.1 Multi-backend selection
|
| 247 |
+
|
| 248 |
+
Multiple backends may serve the same model name (e.g. llama_cpp local + LM Studio remote both offer `qwen2.5-7b-instruct`). They register as separate capability entries. The bus router picks among them by latency/load — no service-internal preference logic.
|
| 249 |
+
|
| 250 |
+
### 4.2 Streaming and cancellation
|
| 251 |
+
|
| 252 |
+
- `handle_chat` is an async generator
|
| 253 |
+
- Each backend `Token` becomes one SSE `token` frame
|
| 254 |
+
- On client disconnect, the generator is cancelled; the backend's `chat()` async iterator receives `GeneratorExit`, propagates cancellation to the underlying library (llama.cpp: set abort flag; HTTP backends: close connection)
|
| 255 |
+
- Cleanup must complete within 200 ms
|
| 256 |
+
|
| 257 |
+
### 4.3 Internet-dependent backends
|
| 258 |
+
|
| 259 |
+
`HfApiBackend` and `AnthropicApiBackend` set `requires_internet=True` on their `BackendModel`. The service still registers them, but the [M09](M09-emergency.md) detector triggers deregistration from the local bus when offline. On restore, they are re-registered.
|
| 260 |
+
|
| 261 |
+
### 4.4 Tool calls (Phase 2)
|
| 262 |
+
|
| 263 |
+
The `tool_call_delta` stream frame in [CONTRACT §4.1](../CAPABILITY_CONTRACT.md) is reserved. Backends that support tool calls (Anthropic, OpenAI, OpenAI-compatible) will emit these in a future version. MVP: ignored / empty.
|
| 264 |
+
|
| 265 |
+
### 4.5 Deterministic mode
|
| 266 |
+
|
| 267 |
+
If `seed` is present in request, backends that support seeded sampling apply it. `llama_cpp` does; HTTP APIs vary. When unsupported, backend still serves but does NOT promise determinism.
|
| 268 |
+
|
| 269 |
+
### 4.6 Token counting
|
| 270 |
+
|
| 271 |
+
Token counts in `meta.tokens_in` / `meta.tokens_out`:
|
| 272 |
+
- `llama_cpp`: exact from the model
|
| 273 |
+
- HTTP backends with usage in response: exact
|
| 274 |
+
- Others: approximate via `tokenizers.count_tokens_approx`
|
| 275 |
+
|
| 276 |
+
---
|
| 277 |
+
|
| 278 |
+
## 5. Errors
|
| 279 |
+
|
| 280 |
+
| Condition | Wire code |
|
| 281 |
+
|-----------|-----------|
|
| 282 |
+
| Unknown model | `not_found` |
|
| 283 |
+
| Backend HTTP 5xx | `internal_error` |
|
| 284 |
+
| Backend HTTP rate limit | `rate_limited` (forwarded; `retry_after_ms` if available) |
|
| 285 |
+
| Empty messages array | `bad_request` |
|
| 286 |
+
| Context exceeded | `bad_request` (with message indicating size) |
|
| 287 |
+
| Generation timed out | `timeout` |
|
| 288 |
+
| Backend crashed mid-stream | emit `error` frame, then close |
|
| 289 |
+
|
| 290 |
+
---
|
| 291 |
+
|
| 292 |
+
## 6. Configuration
|
| 293 |
+
|
| 294 |
+
From [X04 §3](../cross-cutting/X04-config.md):
|
| 295 |
+
|
| 296 |
+
```toml
|
| 297 |
+
[[llm.backends]]
|
| 298 |
+
name = "lmstudio"
|
| 299 |
+
url = "http://192.168.188.25:1234"
|
| 300 |
+
model = "qwen2.5-7b-instruct"
|
| 301 |
+
|
| 302 |
+
[[llm.backends]]
|
| 303 |
+
name = "llama_cpp"
|
| 304 |
+
url = "" # local path; see backend
|
| 305 |
+
model = "qwen2.5-1.5b-instruct-q4_k_m.gguf"
|
| 306 |
+
|
| 307 |
+
[[llm.backends]]
|
| 308 |
+
name = "anthropic_api"
|
| 309 |
+
model = "claude-sonnet-4-6"
|
| 310 |
+
api_key_env = "ANTHROPIC_API_KEY"
|
| 311 |
+
```
|
| 312 |
+
|
| 313 |
+
Constant: `LLM_GENERATION_DEFAULT_TIMEOUT_SECONDS = 120`.
|
| 314 |
+
|
| 315 |
+
---
|
| 316 |
+
|
| 317 |
+
## 7. Tests
|
| 318 |
+
|
| 319 |
+
### Unit
|
| 320 |
+
- `test_capabilities_one_entry_per_model_per_backend`
|
| 321 |
+
- `test_handler_chat_emits_token_then_done`
|
| 322 |
+
- `test_handler_chat_cancellation_within_200ms`
|
| 323 |
+
- `test_params_compatible_model_must_match`
|
| 324 |
+
- `test_params_compatible_ctx_upper_bound`
|
| 325 |
+
- `test_internet_dependent_backend_deregistered_on_offline`
|
| 326 |
+
|
| 327 |
+
### Integration
|
| 328 |
+
- `test_lmstudio_backend_streams_real_tokens` (requires LM Studio at the configured address; skip otherwise)
|
| 329 |
+
- `test_three_node_llm_load_balance`
|
| 330 |
+
- `test_remote_call_through_bus_returns_full_response`
|
| 331 |
+
|
| 332 |
+
---
|
| 333 |
+
|
| 334 |
+
## 8. Cross-references
|
| 335 |
+
|
| 336 |
+
| What | Where |
|
| 337 |
+
|------|-------|
|
| 338 |
+
| `llm.chat@1.0` wire | [CONTRACT §4.1](../CAPABILITY_CONTRACT.md) |
|
| 339 |
+
| `llm.complete@1.0` wire | [CONTRACT §4.2](../CAPABILITY_CONTRACT.md) |
|
| 340 |
+
| Service protocol | [M03 §4](M03-bus.md) |
|
| 341 |
+
| Streaming format | [CONTRACT §5.3](../CAPABILITY_CONTRACT.md), [X01 §6](../cross-cutting/X01-transport.md) |
|
| 342 |
+
| Used by RAG | [M05 §5](M05-rag.md) |
|
| 343 |
+
| Emergency mode deregistration | [M09 §5](M09-emergency.md) |
|
| 344 |
+
|
| 345 |
+
---
|
| 346 |
+
|
| 347 |
+
## 9. Open questions
|
| 348 |
+
|
| 349 |
+
1. **Vision models** — Phase 2; reserved `modalities: ['text','vision']`. Request schema gains `messages[].content[].type='image_url'`.
|
| 350 |
+
2. **Tool calls** — Phase 2; reserved frame `tool_call_delta`. Will integrate Anthropic + OpenAI styles.
|
| 351 |
+
3. **Local model autodiscovery** — should `llama_cpp` backend scan a models directory? Useful but easy to defer.
|
| 352 |
+
4. **Per-model preset profiles** — Phase 2: bind a `system_prompt_template` to a model. Not yet.
|
docs/M05-rag.md
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# M05 — RAG Service
|
| 2 |
+
|
| 3 |
+
**Spec version:** v1.0
|
| 4 |
+
**Depends on:** M03 (bus, for both registration and invoking embed.text), M07 (blobs, for source document storage), X04 (config), X03 (observability), X02 (events, for `rag.document.ingested`), `chromadb`, `pypdf`
|
| 5 |
+
**Depended on by:** M08 (UI), other applications that consume retrieved chunks
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 1. Responsibility
|
| 10 |
+
|
| 11 |
+
Implement `rag.query@1.0`, `rag.ingest@1.0`, `rag.list_corpora@1.0`. Maintain per-corpus vector stores. Chunk and embed ingested documents. Store original document blobs via [M07](M07-file-blobs.md).
|
| 12 |
+
|
| 13 |
+
RAG is **never** the LLM provider — answer generation is a separate hop the caller makes after retrieving chunks. This separation is deliberate: it keeps `rag.query` cacheable and reusable.
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## 2. File layout
|
| 18 |
+
|
| 19 |
+
```
|
| 20 |
+
hearthnet/services/rag/
|
| 21 |
+
├── __init__.py
|
| 22 |
+
├── service.py # RagService
|
| 23 |
+
├── chunker.py # text → chunks
|
| 24 |
+
├── ingest.py # document → chunks → embeddings → store
|
| 25 |
+
└── store.py # ChromaDB wrapper, one collection per corpus
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
---
|
| 29 |
+
|
| 30 |
+
## 3. Public API
|
| 31 |
+
|
| 32 |
+
### 3.1 `chunker.py`
|
| 33 |
+
|
| 34 |
+
```python
|
| 35 |
+
# hearthnet/services/rag/chunker.py
|
| 36 |
+
@dataclass(frozen=True)
|
| 37 |
+
class Chunk:
|
| 38 |
+
text: str
|
| 39 |
+
metadata: dict # {doc_cid, doc_title, page, chunk_index, language}
|
| 40 |
+
|
| 41 |
+
def chunk_text(
|
| 42 |
+
text: str,
|
| 43 |
+
*,
|
| 44 |
+
tokens_per_chunk: int = RAG_CHUNK_TOKENS, # 1000
|
| 45 |
+
overlap_tokens: int = RAG_CHUNK_OVERLAP_TOKENS, # 200
|
| 46 |
+
metadata: dict | None = None,
|
| 47 |
+
) -> list[Chunk]:
|
| 48 |
+
"""Split using a sliding window measured in approximate tokens.
|
| 49 |
+
Respects paragraph boundaries where possible; falls back to sentence then word."""
|
| 50 |
+
|
| 51 |
+
def chunk_pdf(pdf_bytes: bytes, *, doc_metadata: dict) -> list[Chunk]:
|
| 52 |
+
"""Extract text per page using pypdf, then chunk_text per page.
|
| 53 |
+
Each chunk carries page number in metadata."""
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
### 3.2 `store.py`
|
| 57 |
+
|
| 58 |
+
```python
|
| 59 |
+
# hearthnet/services/rag/store.py
|
| 60 |
+
class CorpusStore:
|
| 61 |
+
"""One ChromaDB collection per corpus name."""
|
| 62 |
+
|
| 63 |
+
def __init__(self, corpora_dir: Path, corpus: str, embedding_dim: int):
|
| 64 |
+
...
|
| 65 |
+
|
| 66 |
+
def add_chunks(self, chunks: list[Chunk], embeddings: list[list[float]]) -> None: ...
|
| 67 |
+
def has_document(self, doc_cid: str) -> bool: ...
|
| 68 |
+
def query(
|
| 69 |
+
self,
|
| 70 |
+
embedding: list[float],
|
| 71 |
+
*,
|
| 72 |
+
k: int,
|
| 73 |
+
filter: dict | None = None,
|
| 74 |
+
) -> list[ScoredChunk]: ...
|
| 75 |
+
def count(self) -> int: ...
|
| 76 |
+
def size_bytes(self) -> int: ...
|
| 77 |
+
def language_majority(self) -> str | None: ...
|
| 78 |
+
|
| 79 |
+
@dataclass(frozen=True)
|
| 80 |
+
class ScoredChunk:
|
| 81 |
+
chunk: Chunk
|
| 82 |
+
score: float # similarity, higher = better
|
| 83 |
+
|
| 84 |
+
def list_corpora(corpora_dir: Path) -> list[str]: ...
|
| 85 |
+
def corpus_info(corpora_dir: Path, corpus: str) -> dict: ...
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
### 3.3 `ingest.py`
|
| 89 |
+
|
| 90 |
+
```python
|
| 91 |
+
# hearthnet/services/rag/ingest.py
|
| 92 |
+
class IngestPipeline:
|
| 93 |
+
def __init__(
|
| 94 |
+
self,
|
| 95 |
+
bus: CapabilityBus, # to call embed.text@1.0
|
| 96 |
+
blob_store: BlobStore, # from M07
|
| 97 |
+
corpora_dir: Path,
|
| 98 |
+
event_log: EventLog,
|
| 99 |
+
):
|
| 100 |
+
...
|
| 101 |
+
|
| 102 |
+
async def ingest_document(
|
| 103 |
+
self,
|
| 104 |
+
doc_cid: str,
|
| 105 |
+
corpus: str,
|
| 106 |
+
title: str,
|
| 107 |
+
language: str,
|
| 108 |
+
metadata: dict,
|
| 109 |
+
author_kp: KeyPair,
|
| 110 |
+
) -> IngestResult:
|
| 111 |
+
"""1. Fetch blob bytes from blob_store by doc_cid (assumed already stored).
|
| 112 |
+
2. Detect content type (currently: PDF only).
|
| 113 |
+
3. Chunk.
|
| 114 |
+
4. Batch embed via bus.call('embed.text', (1,0), ...).
|
| 115 |
+
5. Write to CorpusStore.
|
| 116 |
+
6. Append rag.document.ingested event via event_log.
|
| 117 |
+
Idempotent on doc_cid: re-ingesting is a no-op (logged, returns existing result)."""
|
| 118 |
+
|
| 119 |
+
@dataclass(frozen=True)
|
| 120 |
+
class IngestResult:
|
| 121 |
+
doc_cid: str
|
| 122 |
+
chunks_indexed: int
|
| 123 |
+
tokens_indexed: int
|
| 124 |
+
ingest_event_id: str
|
| 125 |
+
ms: int
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
### 3.4 `service.py`
|
| 129 |
+
|
| 130 |
+
```python
|
| 131 |
+
# hearthnet/services/rag/service.py
|
| 132 |
+
class RagService:
|
| 133 |
+
name = "rag"
|
| 134 |
+
version = "1.0"
|
| 135 |
+
|
| 136 |
+
def __init__(
|
| 137 |
+
self,
|
| 138 |
+
config: RagConfig,
|
| 139 |
+
bus: CapabilityBus,
|
| 140 |
+
blob_store: BlobStore,
|
| 141 |
+
event_log: EventLog,
|
| 142 |
+
community_manifest_provider: Callable[[], CommunityManifest],
|
| 143 |
+
):
|
| 144 |
+
self._stores: dict[str, CorpusStore] = {}
|
| 145 |
+
self._ingest = IngestPipeline(bus, blob_store, config.corpora_dir, event_log)
|
| 146 |
+
|
| 147 |
+
def capabilities(self) -> list[tuple[CapabilityDescriptor, Callable, ParamsPredicate]]:
|
| 148 |
+
"""Registers one entry per existing corpus for rag.query (params include corpus name).
|
| 149 |
+
rag.ingest registered once (corpus is a request param).
|
| 150 |
+
rag.list_corpora registered once."""
|
| 151 |
+
|
| 152 |
+
async def start(self) -> None:
|
| 153 |
+
"""Discover existing corpora on disk, open ChromaDB collections."""
|
| 154 |
+
|
| 155 |
+
async def stop(self) -> None: ...
|
| 156 |
+
def health(self) -> dict: ...
|
| 157 |
+
|
| 158 |
+
# --- handlers ---
|
| 159 |
+
|
| 160 |
+
async def handle_query(self, req: RouteRequest) -> dict:
|
| 161 |
+
"""CONTRACT §4.4.
|
| 162 |
+
1. Embed query via bus.call('embed.text', (1,0), ...).
|
| 163 |
+
2. CorpusStore.query(embedding, k).
|
| 164 |
+
3. Format response."""
|
| 165 |
+
|
| 166 |
+
async def handle_ingest(self, req: RouteRequest) -> dict:
|
| 167 |
+
"""CONTRACT §4.5.
|
| 168 |
+
Checks caller is at least 'trusted'.
|
| 169 |
+
Delegates to IngestPipeline.ingest_document."""
|
| 170 |
+
|
| 171 |
+
async def handle_list_corpora(self, req: RouteRequest) -> dict:
|
| 172 |
+
"""CONTRACT §4.6."""
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
### 3.5 Capability descriptors and predicates
|
| 176 |
+
|
| 177 |
+
```python
|
| 178 |
+
# rag.query: registered per corpus
|
| 179 |
+
descriptor_query = CapabilityDescriptor(
|
| 180 |
+
name="rag.query", version=(1, 0), stability="stable",
|
| 181 |
+
request_schema={...}, response_schema={...}, stream_schema=None,
|
| 182 |
+
params={"corpus": "<corpus_name>", "embedding_model": "<model>", "k_max": RAG_MAX_K},
|
| 183 |
+
max_concurrent=4,
|
| 184 |
+
trust_required="member",
|
| 185 |
+
timeout_seconds=10,
|
| 186 |
+
idempotent=True,
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
def query_params_compatible(offered: dict, requested: dict) -> bool:
|
| 190 |
+
return requested.get("corpus") == offered.get("corpus")
|
| 191 |
+
|
| 192 |
+
# rag.ingest: registered once
|
| 193 |
+
descriptor_ingest = CapabilityDescriptor(
|
| 194 |
+
name="rag.ingest", version=(1, 0), stability="stable",
|
| 195 |
+
request_schema={...}, response_schema={...}, stream_schema=None,
|
| 196 |
+
params={"corpora_available": "<list of corpus names>"},
|
| 197 |
+
max_concurrent=2,
|
| 198 |
+
trust_required="trusted",
|
| 199 |
+
timeout_seconds=300,
|
| 200 |
+
idempotent=True,
|
| 201 |
+
)
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
---
|
| 205 |
+
|
| 206 |
+
## 4. Behaviour
|
| 207 |
+
|
| 208 |
+
### 4.1 Embedding via the bus, not direct import
|
| 209 |
+
|
| 210 |
+
`RagService` never imports `EmbeddingService`. It uses `bus.call("embed.text", (1, 0), ...)`. Reasons:
|
| 211 |
+
- Embeddings might run on another node (e.g. a GPU anchor) while RAG runs on a CPU hearth
|
| 212 |
+
- The bus handles load balancing and quarantine automatically
|
| 213 |
+
- Keeps the service module dependency graph honest
|
| 214 |
+
|
| 215 |
+
### 4.2 Corpus naming
|
| 216 |
+
|
| 217 |
+
- `[a-z0-9-]+` only, max 64 chars
|
| 218 |
+
- One corpus per ChromaDB collection
|
| 219 |
+
- Two reserved names: `personal` (per-user, NEVER federated) and `system` (read-only, ships with HearthNet)
|
| 220 |
+
|
| 221 |
+
### 4.3 Ingest idempotency
|
| 222 |
+
|
| 223 |
+
A `(corpus, doc_cid)` already in the store is a no-op. This makes re-ingestion safe across restarts and gossip re-delivery of `rag.document.ingested` events.
|
| 224 |
+
|
| 225 |
+
### 4.4 Event log integration
|
| 226 |
+
|
| 227 |
+
After a successful ingest, append a `rag.document.ingested` event ([X02 §3.1](../cross-cutting/X02-events.md), [CONTRACT §7.2](../CAPABILITY_CONTRACT.md)). Other nodes seeing this event MAY pre-fetch the blob (via `file.read`) and ingest into their own RAG corpus, depending on their replication policy. (Replication policy is out of scope for MVP; nodes do not auto-replicate.)
|
| 228 |
+
|
| 229 |
+
### 4.5 Multi-tenant isolation
|
| 230 |
+
|
| 231 |
+
Each corpus is open in read or read/write mode by the node. The `personal` corpus is local-only and is NEVER routable from other nodes (the service does not register a `rag.query` capability for it).
|
| 232 |
+
|
| 233 |
+
### 4.6 PDF extraction quality
|
| 234 |
+
|
| 235 |
+
`pypdf` is OK for digital PDFs. For scanned PDFs, OCR is needed; this is M-Phase-2 (`ocr.*` namespace). Ingest of a scanned PDF without OCR will produce empty chunks; service detects and returns `bad_request` with hint.
|
| 236 |
+
|
| 237 |
+
### 4.7 Query language detection
|
| 238 |
+
|
| 239 |
+
Optional: detect query language; pass as metadata filter to the store. MVP: detection skipped; caller's filter is respected.
|
| 240 |
+
|
| 241 |
+
---
|
| 242 |
+
|
| 243 |
+
## 5. Composition flow (typical user query)
|
| 244 |
+
|
| 245 |
+
```
|
| 246 |
+
UI → bus.call("llm.chat", ..., body containing user message)
|
| 247 |
+
↓ (handler in LLM service, but UI may also explicitly call rag.query first)
|
| 248 |
+
UI → bus.call("rag.query", (1,0), {params: {corpus: ...}, input: {query: ...}})
|
| 249 |
+
↓
|
| 250 |
+
RagService.handle_query
|
| 251 |
+
→ bus.call("embed.text", (1,0), ...) # may go remote
|
| 252 |
+
→ CorpusStore.query → list[ScoredChunk]
|
| 253 |
+
→ return chunks with metadata
|
| 254 |
+
↓
|
| 255 |
+
UI builds prompt with chunks + question
|
| 256 |
+
UI → bus.call("llm.chat", ..., messages including context)
|
| 257 |
+
```
|
| 258 |
+
|
| 259 |
+
The UI orchestrates this in M08. RAG service does NOT chain into the LLM itself.
|
| 260 |
+
|
| 261 |
+
---
|
| 262 |
+
|
| 263 |
+
## 6. Errors
|
| 264 |
+
|
| 265 |
+
| Condition | Wire code |
|
| 266 |
+
|-----------|-----------|
|
| 267 |
+
| Unknown corpus on query | `not_found` |
|
| 268 |
+
| `k > RAG_MAX_K` | `bad_request` |
|
| 269 |
+
| Blob not resolvable on ingest | `not_found` |
|
| 270 |
+
| Unsupported MIME type on ingest | `bad_request` |
|
| 271 |
+
| Caller not trusted for ingest | `unauthorized` |
|
| 272 |
+
| Embedding model unavailable (no embed.text providers) | `partition` (bus quarantine state) |
|
| 273 |
+
|
| 274 |
+
---
|
| 275 |
+
|
| 276 |
+
## 7. Configuration
|
| 277 |
+
|
| 278 |
+
From [X04 §3](../cross-cutting/X04-config.md):
|
| 279 |
+
|
| 280 |
+
```python
|
| 281 |
+
config.rag.enabled # bool
|
| 282 |
+
config.rag.corpora_dir # default <CACHE>/embeddings
|
| 283 |
+
```
|
| 284 |
+
|
| 285 |
+
Constants: `RAG_CHUNK_TOKENS`, `RAG_CHUNK_OVERLAP_TOKENS`, `RAG_DEFAULT_K`, `RAG_MAX_K`.
|
| 286 |
+
|
| 287 |
+
---
|
| 288 |
+
|
| 289 |
+
## 8. Tests
|
| 290 |
+
|
| 291 |
+
### Unit
|
| 292 |
+
- `test_chunk_text_respects_paragraph_boundaries`
|
| 293 |
+
- `test_chunk_pdf_carries_page_number`
|
| 294 |
+
- `test_corpus_store_add_then_query_recovers_chunk`
|
| 295 |
+
- `test_ingest_idempotent_on_doc_cid`
|
| 296 |
+
- `test_query_handler_calls_embed_via_bus_not_direct_import`
|
| 297 |
+
- `test_query_handler_rejects_unknown_corpus`
|
| 298 |
+
- `test_personal_corpus_not_registered_as_capability`
|
| 299 |
+
|
| 300 |
+
### Integration
|
| 301 |
+
- `test_demo_corpus_query_returns_relevant_chunks` — load the 6 demo PDFs, query, expect top hit
|
| 302 |
+
- `test_ingest_then_other_node_sees_event` — two-node gossip
|
| 303 |
+
- `test_query_falls_back_to_remote_when_local_corpus_missing` — two nodes, only one has corpus
|
| 304 |
+
|
| 305 |
+
---
|
| 306 |
+
|
| 307 |
+
## 9. Cross-references
|
| 308 |
+
|
| 309 |
+
| What | Where |
|
| 310 |
+
|------|-------|
|
| 311 |
+
| `rag.*` wire spec | [CONTRACT §4.4–4.6](../CAPABILITY_CONTRACT.md) |
|
| 312 |
+
| Service protocol | [M03 §4](M03-bus.md) |
|
| 313 |
+
| Uses embed.text | [M11](M11-embedding.md) |
|
| 314 |
+
| Uses blob store | [M07 §3](M07-file-blobs.md) |
|
| 315 |
+
| Emits rag.document.ingested | [X02](../cross-cutting/X02-events.md), [CONTRACT §7.2](../CAPABILITY_CONTRACT.md) |
|
| 316 |
+
| UI query composition | [M08 §4](M08-ui.md) |
|
| 317 |
+
|
| 318 |
+
---
|
| 319 |
+
|
| 320 |
+
## 10. Open questions
|
| 321 |
+
|
| 322 |
+
1. **Re-embedding when models change** — if the configured embedding model changes, the existing corpora are stale. Decision (MVP): refuse to start with mismatched model; print a `hearthnet rag reindex` hint. Phase 2: auto-reindex.
|
| 323 |
+
2. **Federation of corpora** — Phase 2: a corpus may be marked "federated" and queries fan out to other communities. Out of scope here.
|
| 324 |
+
3. **Reranking** — Phase 2: a `rerank.text@1.0` capability could be inserted between embedding and final ranking. Reserved namespace.
|
| 325 |
+
4. **Hybrid search** — keyword + dense. ChromaDB has limited support. Phase 2.
|
docs/M06-marketplace.md
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# M06 — Marketplace Service
|
| 2 |
+
|
| 3 |
+
**Spec version:** v1.0
|
| 4 |
+
**Depends on:** M01 (identity, for signed events), M03 (bus), X02 (events, for storage + replay), X04 (config), X03 (observability), optionally M11 (embedding, for `market.search`)
|
| 5 |
+
**Depended on by:** M08 (UI marketplace tab)
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 1. Responsibility
|
| 10 |
+
|
| 11 |
+
Provide `market.list`, `market.post`, `market.expire`, `market.search` capabilities. Maintain a materialised view of current (non-expired) posts derived from the community event log.
|
| 12 |
+
|
| 13 |
+
The marketplace is **event-sourced**: posts are not stored as a table the service writes to. They are derived from `market.post.*` events in the X02 log. This makes posts automatically signed, durable, and gossipable.
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## 2. File layout
|
| 18 |
+
|
| 19 |
+
```
|
| 20 |
+
hearthnet/services/marketplace/
|
| 21 |
+
├── __init__.py
|
| 22 |
+
├── service.py # MarketplaceService
|
| 23 |
+
├── post.py # Post dataclass + helpers
|
| 24 |
+
└── views.py # MarketplaceView: MaterialisedView from X02
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
## 3. Public API
|
| 30 |
+
|
| 31 |
+
### 3.1 `post.py`
|
| 32 |
+
|
| 33 |
+
```python
|
| 34 |
+
# hearthnet/services/marketplace/post.py
|
| 35 |
+
from dataclasses import dataclass
|
| 36 |
+
from typing import Literal
|
| 37 |
+
|
| 38 |
+
Category = Literal["offer", "request", "info", "emergency"]
|
| 39 |
+
|
| 40 |
+
@dataclass(frozen=True)
|
| 41 |
+
class Location:
|
| 42 |
+
lat: float
|
| 43 |
+
lng: float
|
| 44 |
+
label: str
|
| 45 |
+
|
| 46 |
+
@dataclass(frozen=True)
|
| 47 |
+
class Post:
|
| 48 |
+
"""The marketplace's domain object.
|
| 49 |
+
Derived from a market.post.created event + zero or more market.post.updated events,
|
| 50 |
+
and terminated by a market.post.expired event."""
|
| 51 |
+
event_id: str # ULID of the original .created event
|
| 52 |
+
lamport: int
|
| 53 |
+
author: str # NodeID full form
|
| 54 |
+
category: Category
|
| 55 |
+
title: str
|
| 56 |
+
body: str
|
| 57 |
+
location: Location | None
|
| 58 |
+
tags: list[str]
|
| 59 |
+
created_at: str
|
| 60 |
+
expires_at: str
|
| 61 |
+
expired_via_event_id: str | None # set when expired
|
| 62 |
+
expiry_reason: str | None # "fulfilled" | "withdrawn" | ...
|
| 63 |
+
|
| 64 |
+
def is_expired(self, now: datetime | None = None) -> bool: ...
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
### 3.2 `views.py`
|
| 68 |
+
|
| 69 |
+
```python
|
| 70 |
+
# hearthnet/services/marketplace/views.py
|
| 71 |
+
class MarketplaceView:
|
| 72 |
+
"""MaterialisedView (X02 protocol).
|
| 73 |
+
Subscribes to event_types: market.post.created, .updated, .expired."""
|
| 74 |
+
|
| 75 |
+
def __init__(self): ...
|
| 76 |
+
|
| 77 |
+
# MaterialisedView protocol:
|
| 78 |
+
def reset(self) -> None: ...
|
| 79 |
+
def apply(self, event: Event) -> None: ...
|
| 80 |
+
def snapshot_state(self) -> dict: ...
|
| 81 |
+
def restore_state(self, state: dict) -> None: ...
|
| 82 |
+
|
| 83 |
+
# queries used by the service handlers:
|
| 84 |
+
def list(
|
| 85 |
+
self,
|
| 86 |
+
*,
|
| 87 |
+
category: Category | None = None,
|
| 88 |
+
tags: list[str] | None = None,
|
| 89 |
+
since_lamport: int = 0,
|
| 90 |
+
limit: int = 50,
|
| 91 |
+
) -> list[Post]: ...
|
| 92 |
+
|
| 93 |
+
def get(self, event_id: str) -> Post | None: ...
|
| 94 |
+
def max_lamport(self) -> int: ...
|
| 95 |
+
|
| 96 |
+
# bulk listings for search:
|
| 97 |
+
def all_active(self) -> list[Post]: ...
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
### 3.3 `service.py`
|
| 101 |
+
|
| 102 |
+
```python
|
| 103 |
+
# hearthnet/services/marketplace/service.py
|
| 104 |
+
class MarketplaceService:
|
| 105 |
+
name = "marketplace"
|
| 106 |
+
version = "1.0"
|
| 107 |
+
|
| 108 |
+
def __init__(
|
| 109 |
+
self,
|
| 110 |
+
config: MarketConfig,
|
| 111 |
+
bus: CapabilityBus,
|
| 112 |
+
event_log: EventLog,
|
| 113 |
+
replay_engine: ReplayEngine,
|
| 114 |
+
author_kp: KeyPair, # this node's key, for signing posts
|
| 115 |
+
community_manifest_provider: Callable[[], CommunityManifest],
|
| 116 |
+
):
|
| 117 |
+
self.view = MarketplaceView()
|
| 118 |
+
replay_engine.register(
|
| 119 |
+
"marketplace",
|
| 120 |
+
self.view,
|
| 121 |
+
event_types=["market.post.created", "market.post.updated", "market.post.expired"],
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
def capabilities(self) -> list[tuple[CapabilityDescriptor, Callable, ParamsPredicate]]:
|
| 125 |
+
"""Registers: market.list, market.post, market.expire, market.search."""
|
| 126 |
+
|
| 127 |
+
async def start(self) -> None:
|
| 128 |
+
"""Replay relevant events into the view; install background sweeper for auto-expiry."""
|
| 129 |
+
|
| 130 |
+
async def stop(self) -> None: ...
|
| 131 |
+
def health(self) -> dict: ...
|
| 132 |
+
|
| 133 |
+
# --- handlers ---
|
| 134 |
+
|
| 135 |
+
async def handle_list(self, req: RouteRequest) -> dict:
|
| 136 |
+
"""CONTRACT §4.11."""
|
| 137 |
+
|
| 138 |
+
async def handle_post(self, req: RouteRequest) -> dict:
|
| 139 |
+
"""CONTRACT §4.12.
|
| 140 |
+
Validates ttl ≤ config.market.max_ttl_seconds.
|
| 141 |
+
Idempotency by client_id: if an event with matching (author, client_id) already exists, return its event_id.
|
| 142 |
+
Otherwise: append market.post.created event via event_log.append_local."""
|
| 143 |
+
|
| 144 |
+
async def handle_expire(self, req: RouteRequest) -> dict:
|
| 145 |
+
"""CONTRACT §4.13.
|
| 146 |
+
Checks caller is original author OR trusted moderator.
|
| 147 |
+
Appends market.post.expired event."""
|
| 148 |
+
|
| 149 |
+
async def handle_search(self, req: RouteRequest) -> dict:
|
| 150 |
+
"""CONTRACT §4.14.
|
| 151 |
+
1. bus.call('embed.text', (1,0), {texts: [query]})
|
| 152 |
+
2. For each active post, embed (cached) and score via cosine.
|
| 153 |
+
3. Return top-k.
|
| 154 |
+
Cache embedding per (event_id, body+title hash)."""
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
### 3.4 Capability descriptors
|
| 158 |
+
|
| 159 |
+
```python
|
| 160 |
+
descriptor_list = CapabilityDescriptor(
|
| 161 |
+
name="market.list", version=(1, 0), stability="stable",
|
| 162 |
+
request_schema={...}, response_schema={...}, stream_schema=None,
|
| 163 |
+
params={}, max_concurrent=8,
|
| 164 |
+
trust_required="member", timeout_seconds=5, idempotent=True,
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
descriptor_post = CapabilityDescriptor(
|
| 168 |
+
name="market.post", version=(1, 0), stability="stable",
|
| 169 |
+
request_schema={...}, response_schema={...}, stream_schema=None,
|
| 170 |
+
params={}, max_concurrent=4,
|
| 171 |
+
trust_required="member", timeout_seconds=10, idempotent=True,
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
descriptor_expire = CapabilityDescriptor(
|
| 175 |
+
name="market.expire", version=(1, 0), stability="stable",
|
| 176 |
+
request_schema={...}, response_schema={...}, stream_schema=None,
|
| 177 |
+
params={}, max_concurrent=4,
|
| 178 |
+
trust_required="member", timeout_seconds=5, idempotent=True,
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
descriptor_search = CapabilityDescriptor(
|
| 182 |
+
name="market.search", version=(1, 0), stability="stable",
|
| 183 |
+
request_schema={...}, response_schema={...}, stream_schema=None,
|
| 184 |
+
params={}, max_concurrent=4,
|
| 185 |
+
trust_required="member", timeout_seconds=10, idempotent=True,
|
| 186 |
+
)
|
| 187 |
+
```
|
| 188 |
+
|
| 189 |
+
All four use the default `lambda offered, requested: True` params predicate.
|
| 190 |
+
|
| 191 |
+
---
|
| 192 |
+
|
| 193 |
+
## 4. Behaviour
|
| 194 |
+
|
| 195 |
+
### 4.1 Event-sourcing in practice
|
| 196 |
+
|
| 197 |
+
```
|
| 198 |
+
client → market.post (via bus)
|
| 199 |
+
↓
|
| 200 |
+
service.handle_post
|
| 201 |
+
→ validate
|
| 202 |
+
→ idempotency check on (author, client_id)
|
| 203 |
+
→ event_log.append_local("market.post.created", data, author_kp)
|
| 204 |
+
→ X02 fans out the new event to ReplayEngine
|
| 205 |
+
→ MarketplaceView.apply(event) updates in-memory state
|
| 206 |
+
→ service returns {event_id, lamport}
|
| 207 |
+
```
|
| 208 |
+
|
| 209 |
+
```
|
| 210 |
+
peer sync brings remote market.post.created event
|
| 211 |
+
↓
|
| 212 |
+
event_log.append_received(event)
|
| 213 |
+
→ ReplayEngine triggers MarketplaceView.apply(event)
|
| 214 |
+
→ next market.list call sees the new post
|
| 215 |
+
```
|
| 216 |
+
|
| 217 |
+
The service NEVER writes posts directly. The event log is the only mutator.
|
| 218 |
+
|
| 219 |
+
### 4.2 Auto-expiry sweeper
|
| 220 |
+
|
| 221 |
+
Background task scanning the view every 60 seconds:
|
| 222 |
+
|
| 223 |
+
- For each post where `now >= expires_at` AND no `.expired` event seen: append `market.post.expired` event with reason `"stale"` and the local node as author.
|
| 224 |
+
- This is best-effort; only the post's author or a trusted member is supposed to expire it, but for "stale" reason, any anchor MAY do it.
|
| 225 |
+
|
| 226 |
+
Conflict: if two nodes auto-expire concurrently, both events land, view de-duplicates by `target_event_id`. Last-writer-wins per Lamport (no functional difference — both say "expired").
|
| 227 |
+
|
| 228 |
+
### 4.3 Search caching
|
| 229 |
+
|
| 230 |
+
Embedding all active posts on every search is wasteful. Cache strategy:
|
| 231 |
+
|
| 232 |
+
- Per-post embedding cached in memory keyed by `(event_id, hash(title+body))`
|
| 233 |
+
- On `.updated` event, invalidate
|
| 234 |
+
- Cache size bounded; LRU eviction at 5000 entries
|
| 235 |
+
|
| 236 |
+
Phase 1.5 optimisation. MVP may re-embed each time.
|
| 237 |
+
|
| 238 |
+
### 4.4 Trust check for expire
|
| 239 |
+
|
| 240 |
+
```python
|
| 241 |
+
def can_expire(caller_node_id, post, reason, community_manifest):
|
| 242 |
+
if caller_node_id == post.author:
|
| 243 |
+
return True
|
| 244 |
+
if reason in ("fulfilled", "withdrawn", "user_request"):
|
| 245 |
+
return caller_node_id == post.author # author-only for these reasons
|
| 246 |
+
if reason == "stale":
|
| 247 |
+
return community_manifest.level_of(caller_node_id) in ("trusted", "anchor")
|
| 248 |
+
return False
|
| 249 |
+
```
|
| 250 |
+
|
| 251 |
+
### 4.5 Categories vs tags
|
| 252 |
+
|
| 253 |
+
- `category` is a fixed enum (4 values)
|
| 254 |
+
- `tags` are free-form, sourced from user input; the UI presents popular tags as suggestions
|
| 255 |
+
|
| 256 |
+
### 4.6 Geofencing
|
| 257 |
+
|
| 258 |
+
`location` is advisory. Display only. No filtering in MVP. Phase 2: `market.list` filter on distance.
|
| 259 |
+
|
| 260 |
+
---
|
| 261 |
+
|
| 262 |
+
## 5. Composition with `market.search`
|
| 263 |
+
|
| 264 |
+
```
|
| 265 |
+
UI input: "wasser notfall"
|
| 266 |
+
↓
|
| 267 |
+
bus.call("market.search", (1,0), {input: {query: "wasser notfall", k: 10}})
|
| 268 |
+
→ MarketplaceService.handle_search
|
| 269 |
+
→ bus.call("embed.text", (1,0), {texts: ["wasser notfall"]})
|
| 270 |
+
→ score each active post (cached embedding) → top-k
|
| 271 |
+
→ return posts with scores
|
| 272 |
+
```
|
| 273 |
+
|
| 274 |
+
The marketplace service depends on `embed.text` being available somewhere on the mesh. If embedding is unavailable, search falls back to substring matching (logged at `warning`).
|
| 275 |
+
|
| 276 |
+
---
|
| 277 |
+
|
| 278 |
+
## 6. Errors
|
| 279 |
+
|
| 280 |
+
| Condition | Wire code |
|
| 281 |
+
|-----------|-----------|
|
| 282 |
+
| ttl_seconds > max_ttl_seconds | `bad_request` |
|
| 283 |
+
| caller not authorised to expire | `unauthorized` |
|
| 284 |
+
| target post not found (expire/update) | `not_found` |
|
| 285 |
+
| no embedding provider for search | `partition` (degrades to substring) |
|
| 286 |
+
|
| 287 |
+
---
|
| 288 |
+
|
| 289 |
+
## 7. Configuration
|
| 290 |
+
|
| 291 |
+
From [X04 §3](../cross-cutting/X04-config.md):
|
| 292 |
+
|
| 293 |
+
```python
|
| 294 |
+
config.market.enabled
|
| 295 |
+
config.market.default_ttl_seconds # 7 days
|
| 296 |
+
config.market.max_ttl_seconds # 30 days
|
| 297 |
+
```
|
| 298 |
+
|
| 299 |
+
---
|
| 300 |
+
|
| 301 |
+
## 8. Tests
|
| 302 |
+
|
| 303 |
+
### Unit
|
| 304 |
+
- `test_post_event_appears_in_view_after_apply`
|
| 305 |
+
- `test_post_is_idempotent_on_client_id`
|
| 306 |
+
- `test_expire_unauthorized_caller_rejected`
|
| 307 |
+
- `test_auto_expire_appends_event_with_stale_reason`
|
| 308 |
+
- `test_replay_then_snapshot_then_restore_equal_state`
|
| 309 |
+
- `test_search_falls_back_to_substring_when_embedding_unavailable`
|
| 310 |
+
|
| 311 |
+
### Integration
|
| 312 |
+
- `test_two_node_post_visible_after_sync`
|
| 313 |
+
- `test_three_node_concurrent_posts_all_visible`
|
| 314 |
+
- `test_expire_propagates_then_list_excludes`
|
| 315 |
+
- `test_market_search_returns_relevant`
|
| 316 |
+
|
| 317 |
+
---
|
| 318 |
+
|
| 319 |
+
## 9. Cross-references
|
| 320 |
+
|
| 321 |
+
| What | Where |
|
| 322 |
+
|------|-------|
|
| 323 |
+
| `market.*` wire | [CONTRACT §4.11–4.14](../CAPABILITY_CONTRACT.md) |
|
| 324 |
+
| Event types | [CONTRACT §7.2](../CAPABILITY_CONTRACT.md) |
|
| 325 |
+
| Event log | [X02](../cross-cutting/X02-events.md) |
|
| 326 |
+
| Embed via bus | [M11](M11-embedding.md) |
|
| 327 |
+
| UI marketplace tab | [M08 §5.4](M08-ui.md) |
|
| 328 |
+
|
| 329 |
+
---
|
| 330 |
+
|
| 331 |
+
## 10. Open questions
|
| 332 |
+
|
| 333 |
+
1. **Geographic filter** — Phase 2 with a `location_filter: {center, radius_km}`.
|
| 334 |
+
2. **Moderation tooling** — a "report" flow with admin queue; Phase 2.
|
| 335 |
+
3. **Inter-community marketplace federation** — Phase 2 / 3.
|
| 336 |
+
4. **Encryption of post bodies** — currently cleartext within community. Could encrypt at-rest in event log. Out of scope.
|
docs/M07-file-blobs.md
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# M07 — File & Blobs
|
| 2 |
+
|
| 3 |
+
**Spec version:** v1.0
|
| 4 |
+
**Depends on:** M01 (identity, for signing advertise events), M03 (bus), X01 (transport, for streamed chunk transfer), X02 (events), X04 (config), `blake3`
|
| 5 |
+
**Depended on by:** M05 (RAG stores source PDFs as blobs), M10 (chat attachments), M08 (UI file browser)
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 1. Responsibility
|
| 10 |
+
|
| 11 |
+
Two coupled concerns sharing one module:
|
| 12 |
+
|
| 13 |
+
1. **Blob store** (`hearthnet.blobs.*`): on-disk content-addressed store, chunking, hash verification
|
| 14 |
+
2. **File service** (`hearthnet.services.file.*`): exposes `file.read`, `file.list`, `file.advertise`, `file.put` capabilities to the bus
|
| 15 |
+
|
| 16 |
+
The split exists because the blob store is also used by [M05](M05-rag.md) (for storing source PDFs) without going through the bus.
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## 2. File layout
|
| 21 |
+
|
| 22 |
+
```
|
| 23 |
+
hearthnet/blobs/
|
| 24 |
+
├── __init__.py
|
| 25 |
+
├── store.py # BlobStore: filesystem-backed CID store
|
| 26 |
+
├── chunker.py # split / reassemble + BLAKE3
|
| 27 |
+
└── transfer.py # parallel chunk fetch across multiple sources
|
| 28 |
+
|
| 29 |
+
hearthnet/services/file/
|
| 30 |
+
├── __init__.py
|
| 31 |
+
└── service.py # FileService: registers file.* capabilities
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
---
|
| 35 |
+
|
| 36 |
+
## 3. Blob store (`hearthnet.blobs.*`)
|
| 37 |
+
|
| 38 |
+
### 3.1 `chunker.py`
|
| 39 |
+
|
| 40 |
+
```python
|
| 41 |
+
# hearthnet/blobs/chunker.py
|
| 42 |
+
from dataclasses import dataclass
|
| 43 |
+
|
| 44 |
+
@dataclass(frozen=True)
|
| 45 |
+
class ChunkRef:
|
| 46 |
+
index: int
|
| 47 |
+
cid: str # "blake3:<hex>" of this chunk's bytes
|
| 48 |
+
size_bytes: int
|
| 49 |
+
|
| 50 |
+
@dataclass(frozen=True)
|
| 51 |
+
class BlobManifest:
|
| 52 |
+
cid: str # "blake3:<hex>" — the merkle root, derived from chunks
|
| 53 |
+
size_bytes: int
|
| 54 |
+
chunk_size_bytes: int # CHUNK_SIZE_BYTES = 262144
|
| 55 |
+
chunks: list[ChunkRef]
|
| 56 |
+
mime_type: str | None
|
| 57 |
+
filename: str | None # advisory only, not part of the CID
|
| 58 |
+
|
| 59 |
+
def hash_bytes(data: bytes) -> str:
|
| 60 |
+
"""BLAKE3 → 'blake3:<hex>'."""
|
| 61 |
+
|
| 62 |
+
def chunk_blob(data: bytes, *, chunk_size: int = CHUNK_SIZE_BYTES) -> tuple[BlobManifest, list[bytes]]:
|
| 63 |
+
"""Split bytes into 256KB chunks. Compute per-chunk CID and the merkle-root CID for the manifest."""
|
| 64 |
+
|
| 65 |
+
def manifest_cid(manifest: BlobManifest) -> str:
|
| 66 |
+
"""Compute the manifest's CID: BLAKE3 over canonical-JSON of {chunks[].cid, size_bytes, chunk_size_bytes}."""
|
| 67 |
+
|
| 68 |
+
def reassemble(chunks: list[bytes]) -> bytes:
|
| 69 |
+
"""Concat chunks in index order. Caller is responsible for verifying each chunk's CID first."""
|
| 70 |
+
|
| 71 |
+
def verify_chunk(data: bytes, expected_cid: str) -> None:
|
| 72 |
+
"""Raises BlobError('hash_mismatch') if data's BLAKE3 != expected_cid."""
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
### 3.2 `store.py`
|
| 76 |
+
|
| 77 |
+
```python
|
| 78 |
+
# hearthnet/blobs/store.py
|
| 79 |
+
class BlobStore:
|
| 80 |
+
"""Sharded filesystem store at <DATA>/blobs/<aa>/<bb...>.bin"""
|
| 81 |
+
|
| 82 |
+
def __init__(self, dir_path: Path, gc_threshold: float = BLOB_GC_DISK_THRESHOLD):
|
| 83 |
+
...
|
| 84 |
+
|
| 85 |
+
# -- single-chunk ops --
|
| 86 |
+
|
| 87 |
+
def has(self, cid: str) -> bool: ...
|
| 88 |
+
def read_chunk(self, cid: str) -> bytes:
|
| 89 |
+
"""Raises BlobError('not_found') if absent. Verifies hash on read."""
|
| 90 |
+
def write_chunk(self, cid: str, data: bytes) -> None:
|
| 91 |
+
"""Verifies hash before writing; idempotent if already present."""
|
| 92 |
+
def delete_chunk(self, cid: str) -> bool: ...
|
| 93 |
+
|
| 94 |
+
# -- blob (manifest + chunks) ops --
|
| 95 |
+
|
| 96 |
+
def has_blob(self, manifest_cid: str) -> bool:
|
| 97 |
+
"""True iff the manifest exists AND all referenced chunks exist."""
|
| 98 |
+
|
| 99 |
+
def read_manifest(self, manifest_cid: str) -> BlobManifest: ...
|
| 100 |
+
|
| 101 |
+
def write_blob(self, manifest: BlobManifest, chunks: list[bytes]) -> None:
|
| 102 |
+
"""Atomically write manifest + chunks. Hash-verifies each chunk."""
|
| 103 |
+
|
| 104 |
+
def read_blob_bytes(self, manifest_cid: str) -> bytes:
|
| 105 |
+
"""Reassemble whole blob into memory. For small blobs only (< 100 MB)."""
|
| 106 |
+
|
| 107 |
+
async def read_blob_stream(self, manifest_cid: str) -> AsyncIterator[tuple[ChunkRef, bytes]]:
|
| 108 |
+
"""Stream chunks for large blobs."""
|
| 109 |
+
|
| 110 |
+
# -- introspection / GC --
|
| 111 |
+
|
| 112 |
+
def list_cids(self, prefix: str | None = None) -> list[str]: ...
|
| 113 |
+
def total_bytes(self) -> int: ...
|
| 114 |
+
def pin(self, cid: str) -> None: ...
|
| 115 |
+
def unpin(self, cid: str) -> None: ...
|
| 116 |
+
def is_pinned(self, cid: str) -> bool: ...
|
| 117 |
+
|
| 118 |
+
def gc(self, target_fraction: float = 0.7) -> int:
|
| 119 |
+
"""Run LRU eviction of unpinned blobs until disk usage < target_fraction.
|
| 120 |
+
Returns bytes freed."""
|
| 121 |
+
|
| 122 |
+
class BlobError(Exception):
|
| 123 |
+
"""code in {'not_found','hash_mismatch','io_error','disk_full','manifest_invalid'}"""
|
| 124 |
+
code: str
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
### 3.3 `transfer.py`
|
| 128 |
+
|
| 129 |
+
```python
|
| 130 |
+
# hearthnet/blobs/transfer.py
|
| 131 |
+
class TransferManager:
|
| 132 |
+
"""Coordinates parallel chunk fetch from multiple peer sources."""
|
| 133 |
+
|
| 134 |
+
def __init__(self, store: BlobStore, bus: CapabilityBus, concurrency: int = 4):
|
| 135 |
+
...
|
| 136 |
+
|
| 137 |
+
async def fetch_blob(
|
| 138 |
+
self,
|
| 139 |
+
manifest_cid: str,
|
| 140 |
+
*,
|
| 141 |
+
sources: list[str] | None = None, # NodeIDs known to hold; if None, ask via bus
|
| 142 |
+
) -> BlobManifest:
|
| 143 |
+
"""1. Fetch manifest (via bus.call('file.read', ...)).
|
| 144 |
+
2. Determine missing chunks.
|
| 145 |
+
3. For each missing chunk, pick a source (round-robin, load-aware via bus), fetch via stream.
|
| 146 |
+
4. Verify each chunk, write to store.
|
| 147 |
+
5. Return manifest once complete.
|
| 148 |
+
Resumable: re-running after partial completion skips already-present chunks."""
|
| 149 |
+
|
| 150 |
+
async def advertise(self, cids: list[str]) -> None:
|
| 151 |
+
"""Tell other nodes we hold these CIDs. Emits file.cid.advertised events
|
| 152 |
+
and direct file.advertise calls to known peers (best-effort)."""
|
| 153 |
+
```
|
| 154 |
+
|
| 155 |
+
---
|
| 156 |
+
|
| 157 |
+
## 4. File service (`hearthnet.services.file.*`)
|
| 158 |
+
|
| 159 |
+
### 4.1 `service.py`
|
| 160 |
+
|
| 161 |
+
```python
|
| 162 |
+
# hearthnet/services/file/service.py
|
| 163 |
+
class FileService:
|
| 164 |
+
name = "file"
|
| 165 |
+
version = "1.0"
|
| 166 |
+
|
| 167 |
+
def __init__(self, config: FileConfig, store: BlobStore, event_log: EventLog):
|
| 168 |
+
...
|
| 169 |
+
|
| 170 |
+
def capabilities(self) -> list[tuple[CapabilityDescriptor, Callable, ParamsPredicate]]:
|
| 171 |
+
"""Registers: file.read, file.list, file.advertise, file.put."""
|
| 172 |
+
|
| 173 |
+
async def start(self) -> None: ...
|
| 174 |
+
async def stop(self) -> None: ...
|
| 175 |
+
def health(self) -> dict: ...
|
| 176 |
+
|
| 177 |
+
# --- handlers ---
|
| 178 |
+
|
| 179 |
+
async def handle_read(self, req: RouteRequest) -> AsyncIterator[dict] | dict:
|
| 180 |
+
"""CONTRACT §4.7.
|
| 181 |
+
If CID is a chunk → return single chunk JSON (non-stream).
|
| 182 |
+
If CID is a manifest → stream: 'manifest' frame, then chunks, then 'done'."""
|
| 183 |
+
|
| 184 |
+
async def handle_list(self, req: RouteRequest) -> dict:
|
| 185 |
+
"""CONTRACT §4.8."""
|
| 186 |
+
|
| 187 |
+
async def handle_advertise(self, req: RouteRequest) -> dict:
|
| 188 |
+
"""CONTRACT §4.9.
|
| 189 |
+
Records the caller as a source for the given CIDs in an in-memory index.
|
| 190 |
+
Optionally emits file.cid.advertised event to gossip."""
|
| 191 |
+
|
| 192 |
+
async def handle_put(self, req: RouteRequest) -> AsyncIterator[dict]:
|
| 193 |
+
"""CONTRACT §4.10.
|
| 194 |
+
Client streams chunks; server verifies and stores.
|
| 195 |
+
Requires trust 'trusted' (disk-poisoning risk)."""
|
| 196 |
+
```
|
| 197 |
+
|
| 198 |
+
### 4.2 Capability descriptors
|
| 199 |
+
|
| 200 |
+
```python
|
| 201 |
+
descriptor_read = CapabilityDescriptor(
|
| 202 |
+
name="file.read", version=(1, 0), stability="stable",
|
| 203 |
+
request_schema={...}, response_schema={...},
|
| 204 |
+
stream_schema={...}, # manifest + chunk + done frames
|
| 205 |
+
params={},
|
| 206 |
+
max_concurrent=8,
|
| 207 |
+
trust_required="member",
|
| 208 |
+
timeout_seconds=300,
|
| 209 |
+
idempotent=True,
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
descriptor_list = CapabilityDescriptor(
|
| 213 |
+
name="file.list", version=(1, 0), stability="stable",
|
| 214 |
+
request_schema={...}, response_schema={...}, stream_schema=None,
|
| 215 |
+
params={}, max_concurrent=4,
|
| 216 |
+
trust_required="member", timeout_seconds=5, idempotent=True,
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
descriptor_advertise = CapabilityDescriptor(
|
| 220 |
+
name="file.advertise", version=(1, 0), stability="stable",
|
| 221 |
+
request_schema={...}, response_schema={...}, stream_schema=None,
|
| 222 |
+
params={}, max_concurrent=16,
|
| 223 |
+
trust_required="member", timeout_seconds=5, idempotent=True,
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
descriptor_put = CapabilityDescriptor(
|
| 227 |
+
name="file.put", version=(1, 0), stability="stable",
|
| 228 |
+
request_schema={...}, response_schema={...},
|
| 229 |
+
stream_schema={...}, # ready + chunk + done
|
| 230 |
+
params={}, max_concurrent=2,
|
| 231 |
+
trust_required="trusted", timeout_seconds=600, idempotent=True,
|
| 232 |
+
)
|
| 233 |
+
```
|
| 234 |
+
|
| 235 |
+
### 4.3 `params_compatible`
|
| 236 |
+
|
| 237 |
+
All `file.*` capabilities use the default `lambda offered, requested: True`. Any node with the capability can serve any CID it has; the bus query later checks `has(cid)` and returns `not_found` if absent.
|
| 238 |
+
|
| 239 |
+
---
|
| 240 |
+
|
| 241 |
+
## 5. Behaviour
|
| 242 |
+
|
| 243 |
+
### 5.1 Storage layout on disk
|
| 244 |
+
|
| 245 |
+
```
|
| 246 |
+
<DATA>/blobs/
|
| 247 |
+
├── ab/
|
| 248 |
+
│ ├── c123def456...bin # chunk
|
| 249 |
+
│ └── c123def456...manifest.json
|
| 250 |
+
├── cd/
|
| 251 |
+
│ └── ...
|
| 252 |
+
└── pinned.txt # newline-separated CIDs that must not be GC'd
|
| 253 |
+
```
|
| 254 |
+
|
| 255 |
+
Sharded by first 2 hex chars of CID (256 directories, ~uniform).
|
| 256 |
+
|
| 257 |
+
### 5.2 Hash verification is always-on
|
| 258 |
+
|
| 259 |
+
Every read and every write verifies the chunk's BLAKE3 against its CID. Cost is small (BLAKE3 is fast); benefit is bit-rot detection and protection from a malicious advertiser sending bad bytes.
|
| 260 |
+
|
| 261 |
+
### 5.3 Pinning
|
| 262 |
+
|
| 263 |
+
- The `personal` RAG corpus pins its source documents
|
| 264 |
+
- Snapshot blobs are pinned by [X02](../cross-cutting/X02-events.md)
|
| 265 |
+
- The community manifest blob is pinned
|
| 266 |
+
- User-pinned via UI
|
| 267 |
+
|
| 268 |
+
Anything else is GC-eligible.
|
| 269 |
+
|
| 270 |
+
### 5.4 GC
|
| 271 |
+
|
| 272 |
+
When disk usage exceeds `BLOB_GC_DISK_THRESHOLD` (0.80), evict LRU unpinned blobs until below `0.7 × disk_capacity`. Tracked by file mtime (read updates mtime; common Linux mount option).
|
| 273 |
+
|
| 274 |
+
### 5.5 Source discovery
|
| 275 |
+
|
| 276 |
+
When a node needs a CID it doesn't have:
|
| 277 |
+
|
| 278 |
+
1. Ask the bus: `file.list@1.0` with `prefix=<short CID>` against known peers
|
| 279 |
+
2. Aggregate the responders into a sources list
|
| 280 |
+
3. Optionally also consult `file.cid.advertised` events in the log
|
| 281 |
+
4. Pass sources to `TransferManager.fetch_blob`
|
| 282 |
+
|
| 283 |
+
Phase 2: a DHT-like source index. MVP: per-request fan-out.
|
| 284 |
+
|
| 285 |
+
### 5.6 Concurrent fetches
|
| 286 |
+
|
| 287 |
+
`TransferManager` issues N concurrent chunk fetches across sources. Each chunk request goes via the bus (so health/quarantine work). On per-chunk failure, retry from a different source; after 3 attempts on a chunk, fail the whole blob with `partition`.
|
| 288 |
+
|
| 289 |
+
### 5.7 Backpressure on PUT
|
| 290 |
+
|
| 291 |
+
When a node receives `file.put`, it inspects free disk before accepting. If below 1 GB after putative add, refuse with `capacity_exceeded`.
|
| 292 |
+
|
| 293 |
+
---
|
| 294 |
+
|
| 295 |
+
## 6. Errors
|
| 296 |
+
|
| 297 |
+
| Condition | Wire code |
|
| 298 |
+
|-----------|-----------|
|
| 299 |
+
| CID not present | `not_found` |
|
| 300 |
+
| Hash verification failed | `bad_request` (caller sent corrupted bytes) |
|
| 301 |
+
| Out of disk | `capacity_exceeded` |
|
| 302 |
+
| Caller not trusted for put | `unauthorized` |
|
| 303 |
+
| Chunk size mismatch | `bad_request` |
|
| 304 |
+
|
| 305 |
+
---
|
| 306 |
+
|
| 307 |
+
## 7. Configuration
|
| 308 |
+
|
| 309 |
+
From [X04 §3](../cross-cutting/X04-config.md):
|
| 310 |
+
|
| 311 |
+
```python
|
| 312 |
+
config.file.blobs_dir # default <DATA>/blobs
|
| 313 |
+
config.file.gc_threshold # default 0.8
|
| 314 |
+
```
|
| 315 |
+
|
| 316 |
+
Constants: `CHUNK_SIZE_BYTES`, `BLOB_GC_DISK_THRESHOLD`.
|
| 317 |
+
|
| 318 |
+
---
|
| 319 |
+
|
| 320 |
+
## 8. Tests
|
| 321 |
+
|
| 322 |
+
### Unit
|
| 323 |
+
- `test_chunk_then_reassemble_roundtrip`
|
| 324 |
+
- `test_manifest_cid_deterministic`
|
| 325 |
+
- `test_verify_chunk_detects_one_bit_flip`
|
| 326 |
+
- `test_store_write_then_read_returns_same_bytes`
|
| 327 |
+
- `test_store_rejects_wrong_cid_on_write`
|
| 328 |
+
- `test_pinning_prevents_gc`
|
| 329 |
+
- `test_gc_evicts_lru_unpinned`
|
| 330 |
+
|
| 331 |
+
### Integration
|
| 332 |
+
- `test_two_node_blob_fetch` — node A has blob, node B requests via bus, succeeds
|
| 333 |
+
- `test_three_source_parallel_fetch` — three nodes hold the blob, B fetches in parallel, distribution observed
|
| 334 |
+
- `test_chunk_corruption_falls_over_to_alt_source` — one source returns bad chunk; fetcher retries from another
|
| 335 |
+
- `test_put_requires_trusted_caller`
|
| 336 |
+
|
| 337 |
+
---
|
| 338 |
+
|
| 339 |
+
## 9. Cross-references
|
| 340 |
+
|
| 341 |
+
| What | Where |
|
| 342 |
+
|------|-------|
|
| 343 |
+
| `file.*` wire | [CONTRACT §4.7–4.10](../CAPABILITY_CONTRACT.md) |
|
| 344 |
+
| BLAKE3 + CID format | [GLOSSARY.md](../GLOSSARY.md), [CONTRACT §1.4](../CAPABILITY_CONTRACT.md) |
|
| 345 |
+
| Used by RAG ingest | [M05 §3.3](M05-rag.md) |
|
| 346 |
+
| Used by chat attachments | [M10 §3.3](M10-chat.md) |
|
| 347 |
+
| `file.cid.advertised` event | [CONTRACT §7.2](../CAPABILITY_CONTRACT.md), [X02](../cross-cutting/X02-events.md) |
|
| 348 |
+
| Snapshot pinning | [X02 §5](../cross-cutting/X02-events.md) |
|
| 349 |
+
|
| 350 |
+
---
|
| 351 |
+
|
| 352 |
+
## 10. Open questions
|
| 353 |
+
|
| 354 |
+
1. **DHT source index** — Phase 2 nice-to-have.
|
| 355 |
+
2. **Background replication** — Phase 2: a node may auto-replicate blobs of high "interest" (referenced by recent events). Out of scope MVP.
|
| 356 |
+
3. **Encrypted blobs** — for personal corpus, blobs are stored in cleartext on local disk. Add at-rest encryption Phase 2.
|
| 357 |
+
4. **Resumable PUT mid-transfer crash** — Phase 1.5: server keeps a partial-transfer index keyed by client_id + manifest_cid; client can resume.
|
docs/M08-ui.md
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# M08 — UI (Gradio Dashboard + Mobile Client)
|
| 2 |
+
|
| 3 |
+
**Spec version:** v1.0
|
| 4 |
+
**Depends on:** M03 (bus, the ONLY data source the UI talks to), X03 (observability, for trace display), X04 (config), M09 (emergency state subscribed), `gradio>=6.0.0`
|
| 5 |
+
**Depended on by:** M13 (onboarding extends the UI), M12 (CLI may launch UI)
|
| 6 |
+
|
| 7 |
+
The UI's strict rule: **it never imports a service module**. Every piece of data comes via `bus.call(...)` or via the bus's introspection APIs (`topology_snapshot`, `recent_traces`). This keeps the UI swappable.
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## 1. Responsibility
|
| 12 |
+
|
| 13 |
+
Present a local-host web UI at `http://127.0.0.1:7860` showing:
|
| 14 |
+
|
| 15 |
+
- Live topology of the mesh
|
| 16 |
+
- An "ask" pane wired to `llm.chat` + `rag.query`
|
| 17 |
+
- A chat tab for direct messages
|
| 18 |
+
- A marketplace tab
|
| 19 |
+
- A files tab
|
| 20 |
+
- An emergency tab (visible only when offline)
|
| 21 |
+
- A settings tab
|
| 22 |
+
- A mobile web client served at `/mobile`
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## 2. File layout
|
| 27 |
+
|
| 28 |
+
```
|
| 29 |
+
hearthnet/ui/
|
| 30 |
+
├── __init__.py
|
| 31 |
+
├── app.py # build_ui(): assembles Gradio Blocks
|
| 32 |
+
├── topology.py # Cytoscape.js-backed topology component
|
| 33 |
+
├── theme.py # Colour tokens, fonts, CSS
|
| 34 |
+
├── onboarding.py # M13 owns this; reachable from settings
|
| 35 |
+
├── tabs/
|
| 36 |
+
│ ├── __init__.py
|
| 37 |
+
│ ├── ask.py # LLM passthrough with optional RAG
|
| 38 |
+
│ ├── chat.py # direct messages
|
| 39 |
+
│ ├── marketplace.py
|
| 40 |
+
│ ├── files.py
|
| 41 |
+
│ ├── emergency.py # only mounted when offline state active
|
| 42 |
+
│ └── settings.py
|
| 43 |
+
└── mobile/ # served as static at /mobile
|
| 44 |
+
├── index.html
|
| 45 |
+
├── app.js
|
| 46 |
+
└── style.css
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
---
|
| 50 |
+
|
| 51 |
+
## 3. Public API
|
| 52 |
+
|
| 53 |
+
### 3.1 `app.py`
|
| 54 |
+
|
| 55 |
+
```python
|
| 56 |
+
# hearthnet/ui/app.py
|
| 57 |
+
import gradio as gr
|
| 58 |
+
|
| 59 |
+
class UiApp:
|
| 60 |
+
def __init__(
|
| 61 |
+
self,
|
| 62 |
+
bus: CapabilityBus,
|
| 63 |
+
state_bus: StateBus, # M09
|
| 64 |
+
config: UiConfig,
|
| 65 |
+
node_id_short: str,
|
| 66 |
+
community_name: str,
|
| 67 |
+
):
|
| 68 |
+
...
|
| 69 |
+
|
| 70 |
+
def build(self) -> gr.Blocks:
|
| 71 |
+
"""Assemble the full UI."""
|
| 72 |
+
|
| 73 |
+
async def launch_async(self) -> None:
|
| 74 |
+
"""Non-blocking launch. Used by node.py."""
|
| 75 |
+
|
| 76 |
+
async def shutdown(self) -> None: ...
|
| 77 |
+
|
| 78 |
+
def build_ui(bus, state_bus, config, **meta) -> UiApp:
|
| 79 |
+
"""Convenience constructor used by node.py."""
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
### 3.2 `topology.py`
|
| 83 |
+
|
| 84 |
+
```python
|
| 85 |
+
# hearthnet/ui/topology.py
|
| 86 |
+
class TopologyComponent:
|
| 87 |
+
"""Wraps Cytoscape.js inside a Gradio HTML component.
|
| 88 |
+
Auto-refreshes from bus.topology_snapshot() every 2s.
|
| 89 |
+
Animates recent trace events (last 10s) along edges."""
|
| 90 |
+
|
| 91 |
+
def __init__(self, bus: CapabilityBus): ...
|
| 92 |
+
|
| 93 |
+
def render(self) -> gr.HTML: ...
|
| 94 |
+
|
| 95 |
+
def push_trace(self, event: CallTraceEvent) -> None:
|
| 96 |
+
"""Trigger an edge animation. Color by capability prefix."""
|
| 97 |
+
|
| 98 |
+
def push_topology(self, snapshot: TopologySnapshot) -> None: ...
|
| 99 |
+
|
| 100 |
+
# Cytoscape config:
|
| 101 |
+
# - Nodes: one per known peer + one for self
|
| 102 |
+
# - Edges: dynamic; appear on trace events; fade after 5s
|
| 103 |
+
# - Edge colour: llm.*=teal, rag.*=purple, file.*=amber,
|
| 104 |
+
# chat.*=blue, market.*=green, community.*=grey
|
| 105 |
+
# - Node colour: online=green, stale=amber, offline=red
|
| 106 |
+
# - Node label: display_name + capability badges
|
| 107 |
+
# - On node click: side panel shows full manifest
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
---
|
| 111 |
+
|
| 112 |
+
## 4. Composition
|
| 113 |
+
|
| 114 |
+
The Gradio Blocks tree:
|
| 115 |
+
|
| 116 |
+
```
|
| 117 |
+
gr.Blocks(theme=hearthnet_theme, title="HearthNet")
|
| 118 |
+
├── header bar
|
| 119 |
+
│ ├── community name + node display name
|
| 120 |
+
│ ├── status pill (online/offline) → bound to state_bus
|
| 121 |
+
│ └── settings gear
|
| 122 |
+
├── topology pane (always visible at top)
|
| 123 |
+
└── tabs:
|
| 124 |
+
├── Ask (always)
|
| 125 |
+
├── Chat (always; badge with unread count)
|
| 126 |
+
├── Marketplace (always)
|
| 127 |
+
├── Files (always)
|
| 128 |
+
├── Notfall (visible only when state.mode != "online")
|
| 129 |
+
└── Settings (always; includes Onboarding entry point)
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
---
|
| 133 |
+
|
| 134 |
+
## 5. Tabs
|
| 135 |
+
|
| 136 |
+
### 5.1 Ask tab — `tabs/ask.py`
|
| 137 |
+
|
| 138 |
+
A simple chat interface:
|
| 139 |
+
|
| 140 |
+
- Top: Corpus selector (dropdown, populated via `bus.call("rag.list_corpora", ...)`)
|
| 141 |
+
- Top right: Model selector (capabilities from `bus.topology_snapshot().capabilities_*` filtered by name=`llm.chat`)
|
| 142 |
+
- Centre: Chat history (Gradio Chatbot)
|
| 143 |
+
- Bottom: Input + Send
|
| 144 |
+
|
| 145 |
+
Behaviour on send:
|
| 146 |
+
|
| 147 |
+
```
|
| 148 |
+
1. if corpus selected:
|
| 149 |
+
chunks = bus.call("rag.query", (1,0), {params:{corpus}, input:{query:msg, k:5}})
|
| 150 |
+
build system prompt with chunks + sources
|
| 151 |
+
2. messages = [system_with_chunks, ...history, user_msg]
|
| 152 |
+
3. stream = bus.stream("llm.chat", (1,0), {params:{model}, input:{messages, stream:true}})
|
| 153 |
+
4. accumulate tokens into a streaming response in the Chatbot
|
| 154 |
+
5. on done: append sources panel (clickable to open file)
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
### 5.2 Settings tab — `tabs/settings.py`
|
| 158 |
+
|
| 159 |
+
- Node identity (read-only)
|
| 160 |
+
- Community membership (read-only; "leave community" with double-confirm)
|
| 161 |
+
- LLM backend list (read-only; edit via config.toml)
|
| 162 |
+
- Theme toggle (Hearth / Spark dark mode)
|
| 163 |
+
- Debug toggles (verbose logging, trace ring buffer dump)
|
| 164 |
+
- Onboarding entrypoints: "Create new community", "Join via invite"
|
| 165 |
+
- Privacy: "Erase all data" (triple-confirm, wipes keys + state)
|
| 166 |
+
|
| 167 |
+
### 5.3 Chat tab — `tabs/chat.py`
|
| 168 |
+
|
| 169 |
+
- Left: peer list with last-message timestamps, unread badges
|
| 170 |
+
- Source: `bus.call("chat.history", (1,0), {input:{}})` → group by peer
|
| 171 |
+
- Right: message thread for selected peer
|
| 172 |
+
- Auto-refresh on local pubsub topic `chat.message.<our_short_id>`
|
| 173 |
+
- Bottom: input + send + attachment button (opens file picker → uploads as blob via `file.put` → attaches CID)
|
| 174 |
+
- "Encrypted" indicator placeholder (Phase 2)
|
| 175 |
+
|
| 176 |
+
### 5.4 Marketplace tab — `tabs/marketplace.py`
|
| 177 |
+
|
| 178 |
+
- Top: Category filter, tag filter, search box (semantic)
|
| 179 |
+
- Centre: Cards (one per post)
|
| 180 |
+
- Bottom-right: "Neuer Beitrag" → modal for new post
|
| 181 |
+
- "Mark fulfilled" / "Withdraw" on each card if author == us
|
| 182 |
+
|
| 183 |
+
### 5.5 Files tab — `tabs/files.py`
|
| 184 |
+
|
| 185 |
+
- Left: corpus / pinned / recent
|
| 186 |
+
- Centre: file grid
|
| 187 |
+
- Upload area at top
|
| 188 |
+
- Click: preview (image / PDF / text), download, advertise to peers
|
| 189 |
+
|
| 190 |
+
### 5.6 Emergency tab — `tabs/emergency.py`
|
| 191 |
+
|
| 192 |
+
Visible only when `state_bus.current().mode != "online"`. Designed for big buttons, low-stress reading. Large amber banner at top.
|
| 193 |
+
|
| 194 |
+
Contents:
|
| 195 |
+
|
| 196 |
+
- Big "Was tun?" button → opens the most relevant corpus (default `niederrhein-emergency`)
|
| 197 |
+
- Neighbour list (last seen times prominent)
|
| 198 |
+
- Direct chat shortcut
|
| 199 |
+
- "Update" indicator: how far behind the event log we are vs. last sync
|
| 200 |
+
- Shared resources table (generator availability, water, light) — Phase 2
|
| 201 |
+
|
| 202 |
+
### 5.7 Banner
|
| 203 |
+
|
| 204 |
+
```
|
| 205 |
+
INTERNET OFFLINE — LOKAL AKTIV
|
| 206 |
+
seit 14:32 · 3 Nachbar*innen erreichbar
|
| 207 |
+
```
|
| 208 |
+
|
| 209 |
+
When degraded:
|
| 210 |
+
```
|
| 211 |
+
EINGESCHRÄNKTE VERBINDUNG · Lokale Dienste aktiv
|
| 212 |
+
```
|
| 213 |
+
|
| 214 |
+
---
|
| 215 |
+
|
| 216 |
+
## 6. Mobile client (`mobile/`)
|
| 217 |
+
|
| 218 |
+
Plain static HTML + JS, no framework. Served by [X01](../cross-cutting/X01-transport.md) at `/mobile/*`. Same bus API (signed requests, but credentials stored in `IndexedDB`).
|
| 219 |
+
|
| 220 |
+
Minimum features:
|
| 221 |
+
- Ask (LLM passthrough)
|
| 222 |
+
- Chat
|
| 223 |
+
- Marketplace browse
|
| 224 |
+
- Emergency mode banner
|
| 225 |
+
- No topology viz (too dense for small screen)
|
| 226 |
+
|
| 227 |
+
Auth on mobile: the user scans an invite QR with the camera → key derived in WebCrypto → stored in IndexedDB.
|
| 228 |
+
|
| 229 |
+
---
|
| 230 |
+
|
| 231 |
+
## 7. Theming (`theme.py`)
|
| 232 |
+
|
| 233 |
+
Two themes:
|
| 234 |
+
|
| 235 |
+
- **Hearth (default)** — warm, parchment background, dark walnut accents
|
| 236 |
+
- **Spark (high-contrast / dark)** — black bg, amber accents — also the emergency theme
|
| 237 |
+
|
| 238 |
+
CSS variables:
|
| 239 |
+
|
| 240 |
+
```css
|
| 241 |
+
--hn-bg: #f4ead7; /* hearth */
|
| 242 |
+
--hn-bg-dark: #1a1816; /* spark */
|
| 243 |
+
--hn-accent: #b45309; /* amber */
|
| 244 |
+
--hn-accent-2: #14b8a6; /* teal */
|
| 245 |
+
--hn-accent-3: #6d28d9; /* purple, used for rag */
|
| 246 |
+
--hn-text: #2c1810;
|
| 247 |
+
--hn-text-dark: #f4ead7;
|
| 248 |
+
--hn-error: #b91c1c;
|
| 249 |
+
--hn-warn: #d97706;
|
| 250 |
+
--hn-ok: #15803d;
|
| 251 |
+
```
|
| 252 |
+
|
| 253 |
+
When emergency mode is active, theme switches to Spark with amber accents and the banner.
|
| 254 |
+
|
| 255 |
+
---
|
| 256 |
+
|
| 257 |
+
## 8. Behaviour
|
| 258 |
+
|
| 259 |
+
### 8.1 Topology refresh
|
| 260 |
+
|
| 261 |
+
Every 2 s the topology component calls `bus.topology_snapshot()`. Diff with previous; only changed nodes/edges trigger re-render. Trace ring is read via `bus.recent_traces(50)` every 1 s and pushed as animations.
|
| 262 |
+
|
| 263 |
+
### 8.2 Live updates without polling
|
| 264 |
+
|
| 265 |
+
Where possible the UI subscribes:
|
| 266 |
+
|
| 267 |
+
- `state_bus.subscribe()` for emergency banner
|
| 268 |
+
- `bus.registry.subscribe()` for topology pane (additive)
|
| 269 |
+
- Pubsub `marketplace.post.created` for marketplace tab live refresh
|
| 270 |
+
- Pubsub `chat.message.<our_short_id>` for chat tab notifications
|
| 271 |
+
|
| 272 |
+
### 8.3 Error display
|
| 273 |
+
|
| 274 |
+
- Capability call errors → toast at top with code and "details" expander
|
| 275 |
+
- Backend warm-up takes time → spinner with "Modell wird geladen ..."
|
| 276 |
+
- Network failures during a stream → frame "verbindung abgerissen" injected; user can retry
|
| 277 |
+
|
| 278 |
+
### 8.4 Settings persistence
|
| 279 |
+
|
| 280 |
+
Settings tab edits go to `config.toml` via [X04 §3](../cross-cutting/X04-config.md). Some require restart; UI clearly indicates this.
|
| 281 |
+
|
| 282 |
+
### 8.5 First-run handoff to M13
|
| 283 |
+
|
| 284 |
+
If on startup `config.community.community_id is None`, the UI redirects to onboarding (see [M13](M13-onboarding.md)) instead of showing tabs.
|
| 285 |
+
|
| 286 |
+
---
|
| 287 |
+
|
| 288 |
+
## 9. Configuration
|
| 289 |
+
|
| 290 |
+
From [X04 §3](../cross-cutting/X04-config.md):
|
| 291 |
+
|
| 292 |
+
```python
|
| 293 |
+
config.ui.host # 127.0.0.1
|
| 294 |
+
config.ui.port # 7860
|
| 295 |
+
config.ui.launch_browser # auto-open in browser on launch
|
| 296 |
+
```
|
| 297 |
+
|
| 298 |
+
---
|
| 299 |
+
|
| 300 |
+
## 10. Tests
|
| 301 |
+
|
| 302 |
+
### Unit
|
| 303 |
+
- `test_theme_tokens_present`
|
| 304 |
+
- `test_emergency_tab_hidden_when_online`
|
| 305 |
+
- `test_emergency_tab_shown_when_offline`
|
| 306 |
+
- `test_topology_diff_avoids_unchanged_render` (mock bus)
|
| 307 |
+
- `test_settings_writes_to_config_file_atomically`
|
| 308 |
+
|
| 309 |
+
### Integration
|
| 310 |
+
- `test_ask_tab_does_rag_then_llm_in_order` — mock bus, observe call sequence
|
| 311 |
+
- `test_marketplace_tab_refreshes_on_pubsub_event`
|
| 312 |
+
- `test_mobile_endpoint_serves_index_html`
|
| 313 |
+
|
| 314 |
+
### Manual
|
| 315 |
+
- Demo dry-run script: open UI, type query, observe topology animation, unplug WAN, observe banner ≤ 5s. Document in `tests/demo_script.md`.
|
| 316 |
+
|
| 317 |
+
---
|
| 318 |
+
|
| 319 |
+
## 11. Cross-references
|
| 320 |
+
|
| 321 |
+
| What | Where |
|
| 322 |
+
|------|-------|
|
| 323 |
+
| Bus introspection APIs | [M03 §3.7](M03-bus.md) |
|
| 324 |
+
| Emergency state source | [M09 §3.1](M09-emergency.md) |
|
| 325 |
+
| Pubsub topics | [CONTRACT §8](../CAPABILITY_CONTRACT.md) |
|
| 326 |
+
| Onboarding flow | [M13](M13-onboarding.md) |
|
| 327 |
+
| Mobile served by | [X01 §3.2](../cross-cutting/X01-transport.md) |
|
| 328 |
+
| Trace event format | [M03 §3.6](M03-bus.md) |
|
| 329 |
+
|
| 330 |
+
---
|
| 331 |
+
|
| 332 |
+
## 12. Open questions
|
| 333 |
+
|
| 334 |
+
1. **Gradio version compatibility** — Gradio 6.x evolves quickly. Pin a minor.
|
| 335 |
+
2. **Native mobile** — Phase 2 (Flutter or React Native). Web works for hackathon.
|
| 336 |
+
3. **Accessibility** — colour contrast meets WCAG AA in both themes; not yet audited.
|
| 337 |
+
4. **Internationalisation** — UI strings in German + English. Switchable. Plattdeutsch as a stretch.
|
| 338 |
+
5. **Cytoscape vs D3** — Cytoscape preferred (less code). Performance budget: 50 nodes, 500 edges.
|
docs/M09-emergency.md
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# M09 — Emergency Mode Detector
|
| 2 |
+
|
| 3 |
+
**Spec version:** v1.0
|
| 4 |
+
**Depends on:** M03 (bus, to deregister internet-dependent capabilities), X04 (config), X03 (observability), `httpx`, `socket`
|
| 5 |
+
**Depended on by:** M08 (UI shows banner), M04 (re-registers internet backends on restore), M02 (increases discovery cadence)
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 1. Responsibility
|
| 10 |
+
|
| 11 |
+
Detect whether the node has working internet access. Publish state transitions locally. Cause the bus to deregister/re-register internet-dependent capabilities and let other modules react.
|
| 12 |
+
|
| 13 |
+
Out of scope:
|
| 14 |
+
- VPN / overlay status
|
| 15 |
+
- Per-service connectivity checks
|
| 16 |
+
- Cellular signal strength
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## 2. File layout
|
| 21 |
+
|
| 22 |
+
```
|
| 23 |
+
hearthnet/emergency/
|
| 24 |
+
├── __init__.py
|
| 25 |
+
├── detector.py # Detector: probe loop, state machine
|
| 26 |
+
└── state.py # EmergencyState dataclass + StateBus
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
## 3. Public API
|
| 32 |
+
|
| 33 |
+
### 3.1 `state.py`
|
| 34 |
+
|
| 35 |
+
```python
|
| 36 |
+
# hearthnet/emergency/state.py
|
| 37 |
+
from dataclasses import dataclass
|
| 38 |
+
from typing import Literal
|
| 39 |
+
|
| 40 |
+
Mode = Literal["online", "degraded", "offline"]
|
| 41 |
+
|
| 42 |
+
@dataclass(frozen=True)
|
| 43 |
+
class EmergencyState:
|
| 44 |
+
mode: Mode
|
| 45 |
+
since: str # RFC 3339
|
| 46 |
+
last_probe: str
|
| 47 |
+
probe_results: dict[str, bool] # target → success
|
| 48 |
+
|
| 49 |
+
class StateBus:
|
| 50 |
+
"""In-process pubsub for state changes. UI and other modules subscribe."""
|
| 51 |
+
|
| 52 |
+
def __init__(self): ...
|
| 53 |
+
def current(self) -> EmergencyState: ...
|
| 54 |
+
async def subscribe(self) -> AsyncIterator[EmergencyState]: ...
|
| 55 |
+
def _emit(self, state: EmergencyState) -> None: ... # internal
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
### 3.2 `detector.py`
|
| 59 |
+
|
| 60 |
+
```python
|
| 61 |
+
# hearthnet/emergency/detector.py
|
| 62 |
+
class Detector:
|
| 63 |
+
def __init__(
|
| 64 |
+
self,
|
| 65 |
+
config: EmergencyConfig,
|
| 66 |
+
bus: CapabilityBus,
|
| 67 |
+
state_bus: StateBus,
|
| 68 |
+
):
|
| 69 |
+
...
|
| 70 |
+
|
| 71 |
+
async def run(self) -> None:
|
| 72 |
+
"""Main loop. Cancel-safe.
|
| 73 |
+
Probe cadence:
|
| 74 |
+
- online → every EMERGENCY_PROBE_INTERVAL_ONLINE (10s)
|
| 75 |
+
- degraded → every EMERGENCY_PROBE_INTERVAL_OFFLINE (2s)
|
| 76 |
+
- offline → every EMERGENCY_PROBE_INTERVAL_OFFLINE (2s)
|
| 77 |
+
Each tick:
|
| 78 |
+
1. probe all targets concurrently with 2s timeout
|
| 79 |
+
2. compute new mode
|
| 80 |
+
3. apply debounce (EMERGENCY_TRANSITION_DEBOUNCE_SECONDS, anti-flap)
|
| 81 |
+
4. if mode changed:
|
| 82 |
+
- state_bus._emit(new_state)
|
| 83 |
+
- if entered offline: bus deregisters internet-dependent capabilities
|
| 84 |
+
- if entered online: bus re-registers them
|
| 85 |
+
- emit log + metric
|
| 86 |
+
"""
|
| 87 |
+
|
| 88 |
+
async def shutdown(self) -> None: ...
|
| 89 |
+
|
| 90 |
+
# --- probe primitives ---
|
| 91 |
+
|
| 92 |
+
async def _probe_dns(self, host: str) -> bool: ...
|
| 93 |
+
async def _probe_http(self, url: str) -> bool: ...
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
---
|
| 97 |
+
|
| 98 |
+
## 4. State machine
|
| 99 |
+
|
| 100 |
+
```
|
| 101 |
+
┌────────┐ any probe fails ┌──────────┐
|
| 102 |
+
│ ONLINE ├──────────────────►│ DEGRADED │
|
| 103 |
+
└───┬────┘ └─────┬────┘
|
| 104 |
+
▲ │ ≥2 probes fail for 30s
|
| 105 |
+
│ all probes pass for 10s ▼
|
| 106 |
+
│ ┌──────────┐
|
| 107 |
+
└──────────────────────────┤ OFFLINE │
|
| 108 |
+
└──────────┘
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
Anti-flap: if more than 3 transitions occur within 60 seconds, the detector stays in the more pessimistic state (degraded or offline) until the window passes.
|
| 112 |
+
|
| 113 |
+
---
|
| 114 |
+
|
| 115 |
+
## 5. Behaviour
|
| 116 |
+
|
| 117 |
+
### 5.1 Probes
|
| 118 |
+
|
| 119 |
+
Default targets (from `EmergencyConfig.probe_targets`):
|
| 120 |
+
|
| 121 |
+
- `1.1.1.1` (DNS A query)
|
| 122 |
+
- `8.8.8.8` (DNS A query)
|
| 123 |
+
- `cloudflare.com` (HTTPS HEAD)
|
| 124 |
+
- `quad9.net` (HTTPS HEAD)
|
| 125 |
+
|
| 126 |
+
Mode rule:
|
| 127 |
+
|
| 128 |
+
- `online` requires all 4 succeed
|
| 129 |
+
- `offline` requires ≥ 2 to fail
|
| 130 |
+
- everything between is `degraded`
|
| 131 |
+
|
| 132 |
+
### 5.2 Effects on the bus
|
| 133 |
+
|
| 134 |
+
When entering `offline`:
|
| 135 |
+
|
| 136 |
+
```python
|
| 137 |
+
for entry in bus.registry.all_local():
|
| 138 |
+
if entry.descriptor.params.get("requires_internet"):
|
| 139 |
+
bus.registry.deregister_local(entry.descriptor.name, entry.descriptor.version)
|
| 140 |
+
log.info("offline.deregistered", capability=entry.descriptor.name)
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
When returning to `online`:
|
| 144 |
+
|
| 145 |
+
```python
|
| 146 |
+
for backend in llm_service._backends:
|
| 147 |
+
if backend.requires_internet:
|
| 148 |
+
llm_service._register_backend(backend) # re-emit descriptors
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
`requires_internet` is a convention: services that wrap remote APIs (`anthropic_api`, `hf_api`) set this flag on their `BackendModel` and inject it into the capability descriptor params at registration time.
|
| 152 |
+
|
| 153 |
+
### 5.3 Effects on M02 discovery
|
| 154 |
+
|
| 155 |
+
Detector also calls `peer_registry.set_pruning_aggressive(offline)`:
|
| 156 |
+
|
| 157 |
+
- Offline: prune stale peers after 30 s instead of 90
|
| 158 |
+
- Online: standard 90 s
|
| 159 |
+
|
| 160 |
+
This makes offline mode adapt faster to neighbour churn.
|
| 161 |
+
|
| 162 |
+
### 5.4 UI surface (M08 consumes)
|
| 163 |
+
|
| 164 |
+
The state bus is the source for the amber `INTERNET OFFLINE — LOKAL AKTIV` banner. UI subscribes; flips theme; switches LLM passthrough to local-only backends visibly.
|
| 165 |
+
|
| 166 |
+
### 5.5 Clock sanity probe (only when online)
|
| 167 |
+
|
| 168 |
+
When online for ≥ 30 s, send an extra HEAD to a single anchor and check the `Date` header. If our system clock differs by > 60 s, log a warning. We do NOT auto-correct.
|
| 169 |
+
|
| 170 |
+
### 5.6 No on-wire pubsub
|
| 171 |
+
|
| 172 |
+
`emergency.mode.changed` is local only ([CONTRACT §8](../CAPABILITY_CONTRACT.md)). Other nodes do their own detection.
|
| 173 |
+
|
| 174 |
+
---
|
| 175 |
+
|
| 176 |
+
## 6. Errors
|
| 177 |
+
|
| 178 |
+
This module raises nothing externally; all failures are logged. Internal probe failures are the *normal* signal that drives state.
|
| 179 |
+
|
| 180 |
+
---
|
| 181 |
+
|
| 182 |
+
## 7. Configuration
|
| 183 |
+
|
| 184 |
+
From [X04 §3](../cross-cutting/X04-config.md):
|
| 185 |
+
|
| 186 |
+
```python
|
| 187 |
+
config.emergency.probe_targets # list[str]
|
| 188 |
+
```
|
| 189 |
+
|
| 190 |
+
Constants: `EMERGENCY_PROBE_INTERVAL_ONLINE`, `EMERGENCY_PROBE_INTERVAL_OFFLINE`, `EMERGENCY_PROBE_TIMEOUT_SECONDS`, `EMERGENCY_TRANSITION_DEBOUNCE_SECONDS`.
|
| 191 |
+
|
| 192 |
+
---
|
| 193 |
+
|
| 194 |
+
## 8. Tests
|
| 195 |
+
|
| 196 |
+
### Unit
|
| 197 |
+
- `test_state_transitions_with_synthetic_probes`
|
| 198 |
+
- `test_anti_flap_holds_pessimistic_state`
|
| 199 |
+
- `test_deregister_called_on_offline_entry`
|
| 200 |
+
- `test_reregister_called_on_online_entry`
|
| 201 |
+
|
| 202 |
+
### Integration
|
| 203 |
+
- `test_demo_unplug_triggers_banner_within_5s` — simulate WAN drop with `iptables` rule, observe state change
|
| 204 |
+
|
| 205 |
+
---
|
| 206 |
+
|
| 207 |
+
## 9. Cross-references
|
| 208 |
+
|
| 209 |
+
| What | Where |
|
| 210 |
+
|------|-------|
|
| 211 |
+
| Online/offline pubsub topic (local) | [CONTRACT §8](../CAPABILITY_CONTRACT.md) |
|
| 212 |
+
| LLM internet-dependent backends | [M04 §4.3](M04-llm.md) |
|
| 213 |
+
| Discovery cadence change | [M02 §4.3](M02-discovery.md) |
|
| 214 |
+
| UI banner | [M08 §5.5](M08-ui.md) |
|
| 215 |
+
|
| 216 |
+
---
|
| 217 |
+
|
| 218 |
+
## 10. Open questions
|
| 219 |
+
|
| 220 |
+
1. **Captive portal detection** — Phase 2: probe a known-content URL and compare body hash. MVP: false positives accepted.
|
| 221 |
+
2. **IPv6-only networks** — current probes are dual-stack via OS. Should work; not yet tested.
|
| 222 |
+
3. **Custom probe scripts** — Phase 2: let users add their own targets.
|
docs/M10-chat.md
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# M10 — Chat Service
|
| 2 |
+
|
| 3 |
+
**Spec version:** v1.0
|
| 4 |
+
**Depends on:** M01 (identity, for signing), M03 (bus), X02 (events), X04 (config), X03 (observability), M07 (attachments via blobs)
|
| 5 |
+
**Depended on by:** M08 (UI chat tab)
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 1. Responsibility
|
| 10 |
+
|
| 11 |
+
Provide `chat.send` and `chat.history` capabilities. Handle direct-message delivery: directly if recipient is online; via store-and-forward through an anchor if offline. Maintain a per-peer chat view materialised from `chat.message.*` events.
|
| 12 |
+
|
| 13 |
+
E2E encryption between users is Phase 2 ([CONTRACT §12 open question 1](../CAPABILITY_CONTRACT.md)). MVP relies on TLS-in-transit + signed-at-rest within a trusted community.
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## 2. File layout
|
| 18 |
+
|
| 19 |
+
```
|
| 20 |
+
hearthnet/services/chat/
|
| 21 |
+
├── __init__.py
|
| 22 |
+
├── service.py # ChatService
|
| 23 |
+
├── delivery.py # DeliveryManager: direct vs store-and-forward
|
| 24 |
+
└── views.py # ChatView: MaterialisedView
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
## 3. Public API
|
| 30 |
+
|
| 31 |
+
### 3.1 `views.py`
|
| 32 |
+
|
| 33 |
+
```python
|
| 34 |
+
# hearthnet/services/chat/views.py
|
| 35 |
+
@dataclass(frozen=True)
|
| 36 |
+
class ChatMessage:
|
| 37 |
+
event_id: str
|
| 38 |
+
lamport: int
|
| 39 |
+
sender: str # NodeID full form
|
| 40 |
+
recipient: str
|
| 41 |
+
body: str
|
| 42 |
+
attachments: list[dict] # [{cid, name}]
|
| 43 |
+
created_at: str
|
| 44 |
+
delivered_at: str | None
|
| 45 |
+
read_at: str | None
|
| 46 |
+
|
| 47 |
+
class ChatView:
|
| 48 |
+
"""MaterialisedView from chat.message.sent / .delivered / .read events."""
|
| 49 |
+
|
| 50 |
+
def __init__(self, our_node_id_full: str):
|
| 51 |
+
...
|
| 52 |
+
|
| 53 |
+
# MaterialisedView protocol:
|
| 54 |
+
def reset(self) -> None: ...
|
| 55 |
+
def apply(self, event: Event) -> None: ...
|
| 56 |
+
def snapshot_state(self) -> dict: ...
|
| 57 |
+
def restore_state(self, state: dict) -> None: ...
|
| 58 |
+
|
| 59 |
+
# queries:
|
| 60 |
+
def history_with(
|
| 61 |
+
self,
|
| 62 |
+
peer: str | None = None,
|
| 63 |
+
*,
|
| 64 |
+
since_lamport: int = 0,
|
| 65 |
+
limit: int = 200,
|
| 66 |
+
) -> list[ChatMessage]: ...
|
| 67 |
+
def peers(self) -> list[str]:
|
| 68 |
+
"""All NodeIDs we have exchanged messages with."""
|
| 69 |
+
def unread_count(self, peer: str) -> int: ...
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
### 3.2 `delivery.py`
|
| 73 |
+
|
| 74 |
+
```python
|
| 75 |
+
# hearthnet/services/chat/delivery.py
|
| 76 |
+
class DeliveryManager:
|
| 77 |
+
"""Decides direct vs store-and-forward; performs delivery attempts."""
|
| 78 |
+
|
| 79 |
+
def __init__(
|
| 80 |
+
self,
|
| 81 |
+
bus: CapabilityBus,
|
| 82 |
+
event_log: EventLog,
|
| 83 |
+
author_kp: KeyPair,
|
| 84 |
+
peer_registry: PeerRegistry,
|
| 85 |
+
config: ChatConfig,
|
| 86 |
+
):
|
| 87 |
+
...
|
| 88 |
+
|
| 89 |
+
async def deliver(self, message_event: Event) -> str:
|
| 90 |
+
"""Attempt delivery.
|
| 91 |
+
Returns: 'direct' | 'forwarded' | 'queued'.
|
| 92 |
+
Strategy:
|
| 93 |
+
1. Look up recipient in peer_registry.
|
| 94 |
+
2. If online and reachable: push via pubsub topic chat.message.<recipient_short>.
|
| 95 |
+
3. Else: pick 2 anchors with chat.store_and_forward capability (Phase 2),
|
| 96 |
+
call them with the encrypted-blob carrying message.
|
| 97 |
+
Fall back to leaving it in our log for eventual sync.
|
| 98 |
+
4. Mark with method."""
|
| 99 |
+
|
| 100 |
+
async def on_local_message_arrived(self, message_event: Event) -> None:
|
| 101 |
+
"""When we receive a chat.message.sent event addressed to us:
|
| 102 |
+
- emit pubsub chat.message.<our_short_id> for UI
|
| 103 |
+
- append chat.message.delivered event"""
|
| 104 |
+
|
| 105 |
+
async def on_pubsub_message(self, payload: dict) -> None:
|
| 106 |
+
"""When the pubsub topic delivers a message to us, process it
|
| 107 |
+
(which may include appending the event to our log if not already there)."""
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
### 3.3 `service.py`
|
| 111 |
+
|
| 112 |
+
```python
|
| 113 |
+
# hearthnet/services/chat/service.py
|
| 114 |
+
class ChatService:
|
| 115 |
+
name = "chat"
|
| 116 |
+
version = "1.0"
|
| 117 |
+
|
| 118 |
+
def __init__(
|
| 119 |
+
self,
|
| 120 |
+
config: ChatConfig,
|
| 121 |
+
bus: CapabilityBus,
|
| 122 |
+
event_log: EventLog,
|
| 123 |
+
replay_engine: ReplayEngine,
|
| 124 |
+
peer_registry: PeerRegistry,
|
| 125 |
+
author_kp: KeyPair,
|
| 126 |
+
our_node_id_full: str,
|
| 127 |
+
):
|
| 128 |
+
self.view = ChatView(our_node_id_full=our_node_id_full)
|
| 129 |
+
replay_engine.register(
|
| 130 |
+
"chat",
|
| 131 |
+
self.view,
|
| 132 |
+
event_types=["chat.message.sent", "chat.message.delivered", "chat.message.read"],
|
| 133 |
+
)
|
| 134 |
+
self.delivery = DeliveryManager(bus, event_log, author_kp, peer_registry, config)
|
| 135 |
+
|
| 136 |
+
def capabilities(self) -> list[tuple[CapabilityDescriptor, Callable, ParamsPredicate]]:
|
| 137 |
+
"""Registers: chat.send, chat.history."""
|
| 138 |
+
|
| 139 |
+
async def start(self) -> None:
|
| 140 |
+
"""Replay; subscribe to pubsub topic chat.message.<our_short_id>."""
|
| 141 |
+
|
| 142 |
+
async def stop(self) -> None: ...
|
| 143 |
+
def health(self) -> dict: ...
|
| 144 |
+
|
| 145 |
+
# --- handlers ---
|
| 146 |
+
|
| 147 |
+
async def handle_send(self, req: RouteRequest) -> dict:
|
| 148 |
+
"""CONTRACT §4.15.
|
| 149 |
+
1. Idempotency by (author, client_id).
|
| 150 |
+
2. Append chat.message.sent event.
|
| 151 |
+
3. DeliveryManager.deliver(event) → returns delivery method.
|
| 152 |
+
4. Return {event_id, lamport, delivered}."""
|
| 153 |
+
|
| 154 |
+
async def handle_history(self, req: RouteRequest) -> dict:
|
| 155 |
+
"""CONTRACT §4.16. Self-only: refuses calls where caller != our node_id."""
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
### 3.4 Capability descriptors
|
| 159 |
+
|
| 160 |
+
```python
|
| 161 |
+
descriptor_send = CapabilityDescriptor(
|
| 162 |
+
name="chat.send", version=(1, 0), stability="stable",
|
| 163 |
+
request_schema={...}, response_schema={...}, stream_schema=None,
|
| 164 |
+
params={}, max_concurrent=8,
|
| 165 |
+
trust_required="member", timeout_seconds=15, idempotent=True,
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
descriptor_history = CapabilityDescriptor(
|
| 169 |
+
name="chat.history", version=(1, 0), stability="stable",
|
| 170 |
+
request_schema={...}, response_schema={...}, stream_schema=None,
|
| 171 |
+
params={}, max_concurrent=8,
|
| 172 |
+
trust_required="self", # the bus enforces caller == our_node_id
|
| 173 |
+
timeout_seconds=5, idempotent=True,
|
| 174 |
+
)
|
| 175 |
+
```
|
| 176 |
+
|
| 177 |
+
The `trust_required="self"` is a new level introduced by chat. The bus interprets it as: only the local UI calling through localhost may invoke this capability. Remote callers receive `unauthorized`.
|
| 178 |
+
|
| 179 |
+
---
|
| 180 |
+
|
| 181 |
+
## 4. Behaviour
|
| 182 |
+
|
| 183 |
+
### 4.1 Send → delivery sequence
|
| 184 |
+
|
| 185 |
+
```
|
| 186 |
+
UI → bus.call("chat.send", (1,0), {input: {client_id, recipient, body, attachments}})
|
| 187 |
+
→ ChatService.handle_send
|
| 188 |
+
→ idempotency check
|
| 189 |
+
→ event_log.append_local("chat.message.sent", data, author_kp)
|
| 190 |
+
↳ this fans out to ReplayEngine which calls ChatView.apply()
|
| 191 |
+
→ DeliveryManager.deliver(event)
|
| 192 |
+
↳ if recipient online:
|
| 193 |
+
→ publish to pubsub topic chat.message.<recipient_short>
|
| 194 |
+
↳ else (Phase 2):
|
| 195 |
+
→ bus.call("chat.forward", ...) on two anchors with the capability
|
| 196 |
+
↳ else:
|
| 197 |
+
→ noop, will sync via X02 eventually
|
| 198 |
+
→ return {event_id, lamport, delivered: "direct"|"forwarded"|"queued"}
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
### 4.2 Receive sequence
|
| 202 |
+
|
| 203 |
+
```
|
| 204 |
+
pubsub topic chat.message.<our_short_id> fires with event payload
|
| 205 |
+
→ DeliveryManager.on_pubsub_message
|
| 206 |
+
→ event_log.append_received(event) (deduplicated by event_id)
|
| 207 |
+
↳ ChatView.apply()
|
| 208 |
+
→ event_log.append_local("chat.message.delivered", {target_event_id}, our_kp)
|
| 209 |
+
↳ propagated back to sender via gossip
|
| 210 |
+
→ emit local notification (UI hook)
|
| 211 |
+
```
|
| 212 |
+
|
| 213 |
+
### 4.3 Read receipts (optional)
|
| 214 |
+
|
| 215 |
+
When the UI scrolls past a message, it appends `chat.message.read`. If `config.chat.read_receipts_enabled = false`, the UI doesn't emit it.
|
| 216 |
+
|
| 217 |
+
### 4.4 Store-and-forward (Phase 2 stub)
|
| 218 |
+
|
| 219 |
+
MVP path: if recipient offline, message stays in our log; recipient gets it when they sync. This is fine for community members on the same LAN where everyone gossips.
|
| 220 |
+
|
| 221 |
+
Phase 2: a separate `chat.forward.put@1.0` capability registered by anchors. Sender ships the event to 2 anchors. When recipient reappears, they probe `chat.forward.fetch@1.0` against anchors. After successful delivery, anchors drop the cached event.
|
| 222 |
+
|
| 223 |
+
### 4.5 Attachments
|
| 224 |
+
|
| 225 |
+
`attachments` is a list of `{cid, name}`. The actual blob is sent separately via [M07](M07-file-blobs.md). The chat event only references the CID. Receivers fetch on demand.
|
| 226 |
+
|
| 227 |
+
### 4.6 No self-chat
|
| 228 |
+
|
| 229 |
+
`recipient == our_node_id` is rejected with `bad_request`.
|
| 230 |
+
|
| 231 |
+
### 4.7 Group chat (Phase 2)
|
| 232 |
+
|
| 233 |
+
Reserved `chat.thread.*` namespace. Out of scope here.
|
| 234 |
+
|
| 235 |
+
---
|
| 236 |
+
|
| 237 |
+
## 5. Errors
|
| 238 |
+
|
| 239 |
+
| Condition | Wire code |
|
| 240 |
+
|-----------|-----------|
|
| 241 |
+
| recipient not a community member | `not_found` |
|
| 242 |
+
| caller calling history but not localhost | `unauthorized` |
|
| 243 |
+
| empty body and no attachments | `bad_request` |
|
| 244 |
+
| attachment CID not known to recipient | (silent; recipient fetches via M07 on read) |
|
| 245 |
+
|
| 246 |
+
---
|
| 247 |
+
|
| 248 |
+
## 6. Configuration
|
| 249 |
+
|
| 250 |
+
From [X04 §3](../cross-cutting/X04-config.md):
|
| 251 |
+
|
| 252 |
+
```python
|
| 253 |
+
config.chat.enabled
|
| 254 |
+
config.chat.store_and_forward # Phase 2 flag
|
| 255 |
+
```
|
| 256 |
+
|
| 257 |
+
Phase 2: `config.chat.read_receipts_enabled`.
|
| 258 |
+
|
| 259 |
+
---
|
| 260 |
+
|
| 261 |
+
## 7. Tests
|
| 262 |
+
|
| 263 |
+
### Unit
|
| 264 |
+
- `test_send_appends_event_and_returns_id`
|
| 265 |
+
- `test_send_idempotent_on_client_id`
|
| 266 |
+
- `test_history_rejects_remote_caller`
|
| 267 |
+
- `test_view_apply_updates_state`
|
| 268 |
+
- `test_self_chat_rejected`
|
| 269 |
+
|
| 270 |
+
### Integration
|
| 271 |
+
- `test_two_node_direct_delivery`
|
| 272 |
+
- `test_recipient_offline_then_online_sees_message_after_sync`
|
| 273 |
+
- `test_delivered_event_propagates_back_to_sender`
|
| 274 |
+
|
| 275 |
+
---
|
| 276 |
+
|
| 277 |
+
## 8. Cross-references
|
| 278 |
+
|
| 279 |
+
| What | Where |
|
| 280 |
+
|------|-------|
|
| 281 |
+
| `chat.*` wire | [CONTRACT §4.15–4.16](../CAPABILITY_CONTRACT.md) |
|
| 282 |
+
| Event types | [CONTRACT §7.2](../CAPABILITY_CONTRACT.md) |
|
| 283 |
+
| Event log | [X02](../cross-cutting/X02-events.md) |
|
| 284 |
+
| Attachments | [M07](M07-file-blobs.md) |
|
| 285 |
+
| Pubsub topics | [CONTRACT §8](../CAPABILITY_CONTRACT.md), [X01 §8](../cross-cutting/X01-transport.md) |
|
| 286 |
+
| UI chat tab | [M08 §5.3](M08-ui.md) |
|
| 287 |
+
|
| 288 |
+
---
|
| 289 |
+
|
| 290 |
+
## 9. Open questions
|
| 291 |
+
|
| 292 |
+
1. **E2E encryption** — Phase 2. Will use X25519 + ChaCha20-Poly1305. Body encrypted; envelope (sender, recipient, lamport) stays cleartext. Signature still over ciphertext.
|
| 293 |
+
2. **Group chat** — Phase 2.
|
| 294 |
+
3. **Voice notes** — Phase 2, would use `stt.*` for transcript, blob for audio.
|
| 295 |
+
4. **Phone notifications** — Phase 2, requires the relay tier.
|
docs/M11-embedding.md
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# M11 — Embedding Service
|
| 2 |
+
|
| 3 |
+
**Spec version:** v1.0
|
| 4 |
+
**Depends on:** M03 (bus), X04 (config), X03 (observability), `sentence-transformers`, `torch`
|
| 5 |
+
**Depended on by:** M05 (RAG uses embed.text), M06 (marketplace.search uses embed.text)
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 1. Responsibility
|
| 10 |
+
|
| 11 |
+
Provide capabilities `embed.text@1.0` (and Phase 2 `embed.image@1.0`). Wrap one or more embedding backends, register them with the bus, batch incoming requests for throughput.
|
| 12 |
+
|
| 13 |
+
Embeddings are separated from `llm.*` because their workload is different: small models, high throughput, batchable, often CPU-runnable.
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## 2. File layout
|
| 18 |
+
|
| 19 |
+
```
|
| 20 |
+
hearthnet/services/embedding/
|
| 21 |
+
├── __init__.py
|
| 22 |
+
├── service.py # EmbeddingService
|
| 23 |
+
└── backends.py # SentenceTransformerBackend, OllamaEmbedBackend (Phase 2)
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
## 3. Public API
|
| 29 |
+
|
| 30 |
+
### 3.1 `backends.py`
|
| 31 |
+
|
| 32 |
+
```python
|
| 33 |
+
# hearthnet/services/embedding/backends.py
|
| 34 |
+
from typing import Protocol
|
| 35 |
+
|
| 36 |
+
class EmbeddingBackend(Protocol):
|
| 37 |
+
name: str # "sentence_transformers" | "ollama" | "hf_api"
|
| 38 |
+
model: str # "BAAI/bge-small-en-v1.5"
|
| 39 |
+
dim: int # 384 for bge-small
|
| 40 |
+
max_input: int # max chars per text
|
| 41 |
+
|
| 42 |
+
async def embed(self, texts: list[str], *, normalize: bool = True) -> list[list[float]]: ...
|
| 43 |
+
async def warm(self) -> None: ...
|
| 44 |
+
async def close(self) -> None: ...
|
| 45 |
+
def health(self) -> dict: ...
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class SentenceTransformerBackend:
|
| 49 |
+
"""Local backend using sentence-transformers + torch."""
|
| 50 |
+
|
| 51 |
+
def __init__(self, model: str, device: str = "auto"):
|
| 52 |
+
"""device: 'auto' picks cuda if available else cpu."""
|
| 53 |
+
|
| 54 |
+
async def embed(self, texts, *, normalize=True): ...
|
| 55 |
+
async def warm(self): ...
|
| 56 |
+
async def close(self): ...
|
| 57 |
+
def health(self): ...
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
### 3.2 `service.py`
|
| 61 |
+
|
| 62 |
+
```python
|
| 63 |
+
# hearthnet/services/embedding/service.py
|
| 64 |
+
class EmbeddingService:
|
| 65 |
+
name = "embedding"
|
| 66 |
+
version = "1.0"
|
| 67 |
+
|
| 68 |
+
def __init__(self, config: EmbeddingConfig):
|
| 69 |
+
self._backend: EmbeddingBackend = SentenceTransformerBackend(
|
| 70 |
+
model=config.model, device=config.device
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
def capabilities(self) -> list[tuple[CapabilityDescriptor, Callable, ParamsPredicate]]:
|
| 74 |
+
"""Returns one entry for embed.text@1.0."""
|
| 75 |
+
|
| 76 |
+
async def start(self) -> None: ...
|
| 77 |
+
async def stop(self) -> None: ...
|
| 78 |
+
def health(self) -> dict: ...
|
| 79 |
+
|
| 80 |
+
# --- handler ---
|
| 81 |
+
|
| 82 |
+
async def handle_embed_text(self, req: RouteRequest) -> dict:
|
| 83 |
+
"""Implements embed.text@1.0 (CONTRACT §4.3)."""
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
### 3.3 Capability descriptor (`embed.text@1.0`)
|
| 87 |
+
|
| 88 |
+
```python
|
| 89 |
+
descriptor = CapabilityDescriptor(
|
| 90 |
+
name="embed.text",
|
| 91 |
+
version=(1, 0),
|
| 92 |
+
stability="stable",
|
| 93 |
+
request_schema={
|
| 94 |
+
"type": "object",
|
| 95 |
+
"required": ["params", "input"],
|
| 96 |
+
"properties": {
|
| 97 |
+
"params": {"type": "object", "properties": {
|
| 98 |
+
"model": {"type": "string"},
|
| 99 |
+
}, "required": ["model"]},
|
| 100 |
+
"input": {"type": "object", "required": ["texts"], "properties": {
|
| 101 |
+
"texts": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 256},
|
| 102 |
+
"normalize": {"type": "boolean", "default": True},
|
| 103 |
+
}},
|
| 104 |
+
},
|
| 105 |
+
},
|
| 106 |
+
response_schema={
|
| 107 |
+
"type": "object",
|
| 108 |
+
"required": ["output", "meta"],
|
| 109 |
+
"properties": {
|
| 110 |
+
"output": {"type": "object", "required": ["embeddings", "dim"], "properties": {
|
| 111 |
+
"embeddings": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
|
| 112 |
+
"dim": {"type": "integer"},
|
| 113 |
+
}},
|
| 114 |
+
"meta": {"type": "object", "required": ["model", "ms"]},
|
| 115 |
+
},
|
| 116 |
+
},
|
| 117 |
+
stream_schema=None,
|
| 118 |
+
params={"model": "<from backend>"},
|
| 119 |
+
max_concurrent=8,
|
| 120 |
+
trust_required="member",
|
| 121 |
+
timeout_seconds=15,
|
| 122 |
+
idempotent=True,
|
| 123 |
+
)
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
### 3.4 `params_compatible` predicate
|
| 127 |
+
|
| 128 |
+
```python
|
| 129 |
+
def params_compatible(offered: dict, requested: dict) -> bool:
|
| 130 |
+
# request must specify model; must match offered exactly
|
| 131 |
+
return requested.get("model") == offered.get("model")
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
---
|
| 135 |
+
|
| 136 |
+
## 4. Behaviour
|
| 137 |
+
|
| 138 |
+
### 4.1 Batching (optional optimisation; Phase 1.5)
|
| 139 |
+
|
| 140 |
+
If multiple requests arrive within a small window (e.g. 20 ms), combine their `texts` arrays into one backend call. Demultiplex results back. MVP: no batching (per-request), simpler.
|
| 141 |
+
|
| 142 |
+
### 4.2 Validation
|
| 143 |
+
|
| 144 |
+
- > 256 texts → `bad_request`
|
| 145 |
+
- Any text > 8192 chars → `bad_request`
|
| 146 |
+
- Unknown model → `not_found` (model not loaded; consider asking another node)
|
| 147 |
+
|
| 148 |
+
### 4.3 Resource sizing
|
| 149 |
+
|
| 150 |
+
`max_concurrent = 8` is a sensible default on CPU. On GPU, increase via subclass that overrides `max_concurrent`. The number is declared in the manifest so the bus can throttle correctly.
|
| 151 |
+
|
| 152 |
+
---
|
| 153 |
+
|
| 154 |
+
## 5. Errors
|
| 155 |
+
|
| 156 |
+
Only the universal codes from [CONTRACT §9](../CAPABILITY_CONTRACT.md):
|
| 157 |
+
- `bad_request` — texts too long, too many, etc.
|
| 158 |
+
- `not_found` — model not loaded
|
| 159 |
+
- `internal_error` — backend crash
|
| 160 |
+
|
| 161 |
+
---
|
| 162 |
+
|
| 163 |
+
## 6. Configuration
|
| 164 |
+
|
| 165 |
+
From [X04 §3](../cross-cutting/X04-config.md):
|
| 166 |
+
|
| 167 |
+
```python
|
| 168 |
+
config.embedding.model # "BAAI/bge-small-en-v1.5"
|
| 169 |
+
config.embedding.device # "auto"
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
---
|
| 173 |
+
|
| 174 |
+
## 7. Tests
|
| 175 |
+
|
| 176 |
+
### Unit
|
| 177 |
+
- `test_descriptor_schema_validates_meta_schema`
|
| 178 |
+
- `test_handler_rejects_oversized_text`
|
| 179 |
+
- `test_handler_rejects_too_many_texts`
|
| 180 |
+
- `test_params_compatible_exact_model_match`
|
| 181 |
+
- `test_embed_normalises_to_unit_length`
|
| 182 |
+
|
| 183 |
+
### Integration
|
| 184 |
+
- `test_rag_calls_embed_via_bus` — RAG ingests, embeds via bus.call(), no direct service-to-service import
|
| 185 |
+
- `test_remote_embed_fallback` — local backend not loaded, bus routes to peer
|
| 186 |
+
|
| 187 |
+
---
|
| 188 |
+
|
| 189 |
+
## 8. Cross-references
|
| 190 |
+
|
| 191 |
+
| What | Where |
|
| 192 |
+
|------|-------|
|
| 193 |
+
| `embed.text@1.0` wire spec | [CONTRACT §4.3](../CAPABILITY_CONTRACT.md) |
|
| 194 |
+
| Service protocol | [M03 §4](M03-bus.md) |
|
| 195 |
+
| Consumed by RAG | [M05 §5](M05-rag.md) |
|
| 196 |
+
| Consumed by marketplace.search | [M06 §5](M06-marketplace.md) |
|
| 197 |
+
|
| 198 |
+
---
|
| 199 |
+
|
| 200 |
+
## 9. Open questions
|
| 201 |
+
|
| 202 |
+
1. **Phase 2 `embed.image@1.0` (CLIP)** — adds an image backend; `params` includes modality. Reserved.
|
| 203 |
+
2. **Batching policy** — measured trade-off; deferred. Defaults to immediate dispatch in MVP.
|
docs/M12-cli.md
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# M12 — CLI
|
| 2 |
+
|
| 3 |
+
**Spec version:** v1.0
|
| 4 |
+
**Depends on:** X04 (config), M01 (identity), M03 (bus, via IPC), X03 (observability for doctor), `click`
|
| 5 |
+
**Depended on by:** Users; packaging
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 1. Responsibility
|
| 10 |
+
|
| 11 |
+
Provide the `hearthnet` command. Each subcommand is small, scriptable, exit-code-correct. The CLI either:
|
| 12 |
+
|
| 13 |
+
- Runs in **standalone mode**: does not need a running node (init, doctor on cold disk, export, erase)
|
| 14 |
+
- Talks to a **running node** over local HTTP (`status`, `caps`, `log`, `trace`), bypassing the UI
|
| 15 |
+
|
| 16 |
+
The CLI never imports a service module. For node-state queries it uses the bus's HTTP API on `127.0.0.1:7080` like any other client.
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## 2. File layout
|
| 21 |
+
|
| 22 |
+
```
|
| 23 |
+
hearthnet/
|
| 24 |
+
├── cli.py # Click app, all subcommands
|
| 25 |
+
├── __main__.py # `python -m hearthnet` → cli.main()
|
| 26 |
+
└── doctor.py # re-export from X03 for `hearthnet doctor`
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
Installed as console script in `pyproject.toml`:
|
| 30 |
+
|
| 31 |
+
```toml
|
| 32 |
+
[project.scripts]
|
| 33 |
+
hearthnet = "hearthnet.cli:main"
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
+
## 3. Subcommands
|
| 39 |
+
|
| 40 |
+
### 3.1 `hearthnet init`
|
| 41 |
+
|
| 42 |
+
```
|
| 43 |
+
hearthnet init [--name NAME] [--profile PROFILE] [--non-interactive]
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
Bootstraps a new node:
|
| 47 |
+
|
| 48 |
+
1. Resolves XDG paths, creates dirs
|
| 49 |
+
2. Generates keypair if absent (M01)
|
| 50 |
+
3. Writes default `config.toml`
|
| 51 |
+
4. Interactive prompts (unless `--non-interactive`):
|
| 52 |
+
- Display name
|
| 53 |
+
- Profile (auto-detected from hardware)
|
| 54 |
+
- Create or join community
|
| 55 |
+
5. If create: builds genesis community manifest, writes it, prints invite QR to terminal (Unicode block art) and saves PNG
|
| 56 |
+
6. If join: prompts for invite text, redeems
|
| 57 |
+
|
| 58 |
+
Exits 0 on success, 2 on user abort, 1 on error.
|
| 59 |
+
|
| 60 |
+
### 3.2 `hearthnet run`
|
| 61 |
+
|
| 62 |
+
```
|
| 63 |
+
hearthnet run [--config PATH] [--no-ui] [--debug]
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
Starts the node:
|
| 67 |
+
|
| 68 |
+
1. Loads config (X04)
|
| 69 |
+
2. Configures observability (X03)
|
| 70 |
+
3. Loads keypair (M01) — refuses if missing
|
| 71 |
+
4. Verifies community manifest present — if not, redirects to init
|
| 72 |
+
5. Composes the node (see [`node.py` in the package layout](#5-the-orchestrator-nodepy))
|
| 73 |
+
6. Blocks until SIGINT / SIGTERM
|
| 74 |
+
|
| 75 |
+
`--no-ui` skips Gradio (useful for headless anchor / RPi).
|
| 76 |
+
`--debug` raises log level to debug.
|
| 77 |
+
|
| 78 |
+
### 3.3 `hearthnet status`
|
| 79 |
+
|
| 80 |
+
```
|
| 81 |
+
hearthnet status [--json]
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
Connects to local node at `127.0.0.1:7080`. Reports:
|
| 85 |
+
|
| 86 |
+
- Our node ID + display name + profile
|
| 87 |
+
- Community ID + name + member count
|
| 88 |
+
- Online state (online/degraded/offline) + duration in this state
|
| 89 |
+
- Peers visible (count + summaries)
|
| 90 |
+
- Registered local capabilities (count + names)
|
| 91 |
+
- In-flight calls
|
| 92 |
+
- Event log head Lamport
|
| 93 |
+
- Disk usage (blobs + events)
|
| 94 |
+
|
| 95 |
+
Exits 0 if reachable, 3 if not reachable, 1 on bad response.
|
| 96 |
+
|
| 97 |
+
### 3.4 `hearthnet caps`
|
| 98 |
+
|
| 99 |
+
```
|
| 100 |
+
hearthnet caps [--remote-only | --local-only] [--name PATTERN]
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
Lists capability entries. Columns: `name`, `version`, `stability`, `node`, `model/params`, `health`, `p50ms`, `in_flight`.
|
| 104 |
+
|
| 105 |
+
### 3.5 `hearthnet call`
|
| 106 |
+
|
| 107 |
+
```
|
| 108 |
+
hearthnet call NAME[@VERSION] --body '<json>' [--stream]
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
Make a one-shot capability call. Useful for scripting and testing.
|
| 112 |
+
|
| 113 |
+
```
|
| 114 |
+
hearthnet call llm.chat@1.0 --stream \
|
| 115 |
+
--body '{"params":{"model":"qwen2.5-7b-instruct"},"input":{"messages":[{"role":"user","content":"Hi"}]}}'
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
Streams to stdout. Non-zero exit code reflects wire error code (mapped: see [CONTRACT §9](../CAPABILITY_CONTRACT.md)).
|
| 119 |
+
|
| 120 |
+
### 3.6 `hearthnet log`
|
| 121 |
+
|
| 122 |
+
```
|
| 123 |
+
hearthnet log [--follow] [--level LEVEL] [--component NAME]
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
Tails the structured log file. With `--follow`, behaves like `tail -F` and filters live.
|
| 127 |
+
|
| 128 |
+
### 3.7 `hearthnet trace`
|
| 129 |
+
|
| 130 |
+
```
|
| 131 |
+
hearthnet trace recent [N] [--capability NAME]
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
Pulls the trace ring buffer via `/trace/recent`. Pretty-prints last N traces.
|
| 135 |
+
|
| 136 |
+
### 3.8 `hearthnet doctor`
|
| 137 |
+
|
| 138 |
+
```
|
| 139 |
+
hearthnet doctor [--check NAME]
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
Runs X03's self-diagnostics (`run_all` or `run_one`). Coloured terminal output:
|
| 143 |
+
|
| 144 |
+
```
|
| 145 |
+
✔ keys_present /home/christof/.local/share/hearthnet/keys/device.ed25519
|
| 146 |
+
✔ keys_loadable Ed25519, 32 bytes
|
| 147 |
+
✘ mdns_socket Port 5353 in use by avahi-daemon
|
| 148 |
+
→ fix: sudo systemctl stop avahi-daemon
|
| 149 |
+
...
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
Exit code: 0 if all pass, 1 if any fail, 2 if doctor itself crashed.
|
| 153 |
+
|
| 154 |
+
### 3.9 `hearthnet export`
|
| 155 |
+
|
| 156 |
+
```
|
| 157 |
+
hearthnet export [--out PATH]
|
| 158 |
+
```
|
| 159 |
+
|
| 160 |
+
Exports all local data for this user (GDPR right-to-export):
|
| 161 |
+
|
| 162 |
+
- Public manifest
|
| 163 |
+
- Our authored events
|
| 164 |
+
- Our chat history
|
| 165 |
+
- Our pinned files (CIDs + filenames)
|
| 166 |
+
- Our marketplace posts
|
| 167 |
+
- Settings (without secrets)
|
| 168 |
+
|
| 169 |
+
Output: a signed ZIP at `<PATH>` (default `~/hearthnet-export-<date>.zip`).
|
| 170 |
+
|
| 171 |
+
### 3.10 `hearthnet erase`
|
| 172 |
+
|
| 173 |
+
```
|
| 174 |
+
hearthnet erase [--keep-keys] [--yes]
|
| 175 |
+
```
|
| 176 |
+
|
| 177 |
+
Erases local state. Prompts thrice. With `--keep-keys`, retains the device key (allowing rejoin later).
|
| 178 |
+
|
| 179 |
+
Order of erase:
|
| 180 |
+
|
| 181 |
+
1. Stop running node (best-effort over IPC)
|
| 182 |
+
2. Wipe `<DATA>/communities/<id>/` (events, manifests, snapshots)
|
| 183 |
+
3. Wipe `<DATA>/blobs/` (unless pinned with `--keep-blobs`)
|
| 184 |
+
4. Wipe `<CACHE>/embeddings/`
|
| 185 |
+
5. Wipe `<LOG>` (unless `--keep-logs`)
|
| 186 |
+
6. Wipe `<DATA>/keys/` unless `--keep-keys`
|
| 187 |
+
7. Print summary
|
| 188 |
+
|
| 189 |
+
### 3.11 `hearthnet rag`
|
| 190 |
+
|
| 191 |
+
```
|
| 192 |
+
hearthnet rag list
|
| 193 |
+
hearthnet rag ingest PATH --corpus NAME
|
| 194 |
+
hearthnet rag reindex --corpus NAME [--embedding-model MODEL]
|
| 195 |
+
```
|
| 196 |
+
|
| 197 |
+
Local CLI for RAG operations. Calls `rag.list_corpora`, `rag.ingest`, and (for reindex) a privileged local-only flow that re-embeds an existing corpus.
|
| 198 |
+
|
| 199 |
+
### 3.12 `hearthnet invite`
|
| 200 |
+
|
| 201 |
+
```
|
| 202 |
+
hearthnet invite create --node-id NODEID --level LEVEL --ttl HOURS
|
| 203 |
+
hearthnet invite redeem TEXT_OR_PATH
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
CLI equivalents of the M13 onboarding flows. Useful for headless anchors.
|
| 207 |
+
|
| 208 |
+
### 3.13 `hearthnet version`
|
| 209 |
+
|
| 210 |
+
Prints `__version__`, contract version, Python version, OS. One line.
|
| 211 |
+
|
| 212 |
+
---
|
| 213 |
+
|
| 214 |
+
## 4. CLI architecture (Click)
|
| 215 |
+
|
| 216 |
+
```python
|
| 217 |
+
# hearthnet/cli.py
|
| 218 |
+
import click
|
| 219 |
+
|
| 220 |
+
@click.group()
|
| 221 |
+
@click.option("--config", type=click.Path(), help="Path to config.toml")
|
| 222 |
+
@click.pass_context
|
| 223 |
+
def main(ctx, config):
|
| 224 |
+
ctx.obj = load_config(Path(config) if config else None)
|
| 225 |
+
|
| 226 |
+
@main.command()
|
| 227 |
+
@click.option("--name")
|
| 228 |
+
...
|
| 229 |
+
def init(...): ...
|
| 230 |
+
|
| 231 |
+
# ... etc. Each subcommand is its own function.
|
| 232 |
+
```
|
| 233 |
+
|
| 234 |
+
Each command function is < 40 lines and delegates to module-level helpers in the same file. Tests can call the helpers directly without invoking Click runtime.
|
| 235 |
+
|
| 236 |
+
---
|
| 237 |
+
|
| 238 |
+
## 5. The orchestrator (`node.py`)
|
| 239 |
+
|
| 240 |
+
The CLI's `run` subcommand calls into `hearthnet.node.start`. This is not strictly part of M12 but is documented here for completeness because it's the central wiring point.
|
| 241 |
+
|
| 242 |
+
```python
|
| 243 |
+
# hearthnet/node.py
|
| 244 |
+
async def start(config: Config) -> None:
|
| 245 |
+
# 1. observability
|
| 246 |
+
observability.logging.configure(config.observability)
|
| 247 |
+
observability.metrics.configure(config.observability)
|
| 248 |
+
|
| 249 |
+
# 2. identity
|
| 250 |
+
kp = identity.keys.load_or_generate(config.identity.keys_dir)
|
| 251 |
+
|
| 252 |
+
# 3. community check (M13 redirect if missing)
|
| 253 |
+
if config.community.community_id is None:
|
| 254 |
+
await onboarding.run_blocking(config, kp) # writes config; restart cycle
|
| 255 |
+
return
|
| 256 |
+
|
| 257 |
+
# 4. core state
|
| 258 |
+
event_log = events.EventLog(config.community.state_dir / "events.sqlite",
|
| 259 |
+
config.community.community_id)
|
| 260 |
+
snapshot_store = events.SnapshotStore(config.community.state_dir / "snapshots",
|
| 261 |
+
config.community.community_id)
|
| 262 |
+
replay_engine = events.ReplayEngine(event_log)
|
| 263 |
+
community_manifest = identity.manifest.load_or_regenerate(...)
|
| 264 |
+
|
| 265 |
+
# 5. blobs
|
| 266 |
+
blob_store = blobs.BlobStore(config.file.blobs_dir, gc_threshold=config.file.gc_threshold)
|
| 267 |
+
|
| 268 |
+
# 6. transport + bus
|
| 269 |
+
pinned = transport.PinnedCerts(...)
|
| 270 |
+
http_client = transport.HttpClient(kp, kp.node_id_full, config.community.community_id, pinned)
|
| 271 |
+
bus = CapabilityBus(kp.node_id_full, config.community.community_id, config.bus,
|
| 272 |
+
http_client, lambda: community_manifest)
|
| 273 |
+
|
| 274 |
+
# 7. peer registry + discovery
|
| 275 |
+
peer_registry = discovery.PeerRegistry(kp.node_id_full, config.community.community_id)
|
| 276 |
+
mdns_announcer = discovery.MdnsAnnouncer(...)
|
| 277 |
+
mdns_browser = discovery.MdnsBrowser(peer_registry, config.community.community_id)
|
| 278 |
+
udp_announcer = discovery.UdpAnnouncer(...)
|
| 279 |
+
udp_listener = discovery.UdpListener(peer_registry, config.community.community_id)
|
| 280 |
+
|
| 281 |
+
# 8. services
|
| 282 |
+
services_list = []
|
| 283 |
+
if config.embedding:
|
| 284 |
+
services_list.append(EmbeddingService(config.embedding))
|
| 285 |
+
if config.llm.backends:
|
| 286 |
+
services_list.append(LlmService(config.llm))
|
| 287 |
+
if config.rag.enabled:
|
| 288 |
+
services_list.append(RagService(config.rag, bus, blob_store, event_log, lambda: community_manifest))
|
| 289 |
+
if config.file:
|
| 290 |
+
services_list.append(FileService(config.file, blob_store, event_log))
|
| 291 |
+
if config.market.enabled:
|
| 292 |
+
services_list.append(MarketplaceService(config.market, bus, event_log, replay_engine, kp,
|
| 293 |
+
lambda: community_manifest))
|
| 294 |
+
if config.chat.enabled:
|
| 295 |
+
services_list.append(ChatService(config.chat, bus, event_log, replay_engine, peer_registry, kp,
|
| 296 |
+
kp.node_id_full))
|
| 297 |
+
|
| 298 |
+
for s in services_list:
|
| 299 |
+
bus.register_service(s)
|
| 300 |
+
await s.start()
|
| 301 |
+
|
| 302 |
+
# 9. emergency detector
|
| 303 |
+
state_bus = emergency.StateBus()
|
| 304 |
+
detector = emergency.Detector(config.emergency, bus, state_bus)
|
| 305 |
+
|
| 306 |
+
# 10. transport server
|
| 307 |
+
http_server = transport.HttpServer(config.transport, kp, bus,
|
| 308 |
+
event_sync=events.SyncServer(event_log),
|
| 309 |
+
community_manifest_provider=lambda: community_manifest)
|
| 310 |
+
|
| 311 |
+
# 11. UI
|
| 312 |
+
ui_app = ui.build_ui(bus, state_bus, config.ui,
|
| 313 |
+
node_id_short=kp.node_id_short, community_name=community_manifest.name)
|
| 314 |
+
|
| 315 |
+
# 12. wire peer events → bus
|
| 316 |
+
peer_registry.subscribe(...).on_event(bus.on_peer_added, bus.on_peer_updated, bus.on_peer_removed)
|
| 317 |
+
|
| 318 |
+
# 13. periodic manifest publish
|
| 319 |
+
publisher = ManifestPublisher(kp, community_manifest_provider=..., bus=bus,
|
| 320 |
+
peer_registry=peer_registry, interval_seconds=MANIFEST_REPUBLISH_INTERVAL_SECONDS)
|
| 321 |
+
|
| 322 |
+
# 14. periodic sync
|
| 323 |
+
syncer = events.SyncClient(event_log, http_client)
|
| 324 |
+
sync_loop = PeriodicTask(lambda: syncer.run_round(peer_registry), interval_seconds=300)
|
| 325 |
+
|
| 326 |
+
# 15. run everything
|
| 327 |
+
await asyncio.gather(
|
| 328 |
+
http_server.run(),
|
| 329 |
+
mdns_announcer.start(), mdns_browser.start(),
|
| 330 |
+
udp_announcer.run(), udp_listener.run(),
|
| 331 |
+
detector.run(),
|
| 332 |
+
publisher.run(),
|
| 333 |
+
sync_loop.run(),
|
| 334 |
+
ui_app.launch_async(),
|
| 335 |
+
)
|
| 336 |
+
```
|
| 337 |
+
|
| 338 |
+
This is the canonical wiring. Anything that looks different across modules is wrong.
|
| 339 |
+
|
| 340 |
+
---
|
| 341 |
+
|
| 342 |
+
## 6. Exit code reference
|
| 343 |
+
|
| 344 |
+
| Code | Meaning |
|
| 345 |
+
|------|---------|
|
| 346 |
+
| 0 | Success |
|
| 347 |
+
| 1 | Generic error (see stderr) |
|
| 348 |
+
| 2 | User aborted / bad usage |
|
| 349 |
+
| 3 | No running node (for commands needing IPC) |
|
| 350 |
+
| 4 | Auth / signature failure |
|
| 351 |
+
| 5 | Disk full / capacity exceeded |
|
| 352 |
+
|
| 353 |
+
---
|
| 354 |
+
|
| 355 |
+
## 7. Configuration
|
| 356 |
+
|
| 357 |
+
The CLI reads the same `config.toml` as the daemon. `--config` overrides the path.
|
| 358 |
+
|
| 359 |
+
---
|
| 360 |
+
|
| 361 |
+
## 8. Tests
|
| 362 |
+
|
| 363 |
+
### Unit (per subcommand handler)
|
| 364 |
+
- `test_init_writes_config_and_keys`
|
| 365 |
+
- `test_status_against_mock_node_returns_table`
|
| 366 |
+
- `test_call_streams_stdout_then_zero`
|
| 367 |
+
- `test_doctor_exit_code_reflects_failures`
|
| 368 |
+
- `test_erase_keep_keys`
|
| 369 |
+
|
| 370 |
+
### Integration
|
| 371 |
+
- `test_full_init_then_run_then_status` — spawn subprocess, await readiness, query
|
| 372 |
+
- `test_call_returns_nonzero_on_wire_error`
|
| 373 |
+
- `test_export_zip_is_signed_and_parseable`
|
| 374 |
+
|
| 375 |
+
---
|
| 376 |
+
|
| 377 |
+
## 9. Cross-references
|
| 378 |
+
|
| 379 |
+
| What | Where |
|
| 380 |
+
|------|-------|
|
| 381 |
+
| Self-diagnostics | [X03 §6](../cross-cutting/X03-observability.md) |
|
| 382 |
+
| Onboarding helpers | [M13](M13-onboarding.md) |
|
| 383 |
+
| Bus introspection endpoints | [M03 §3.7](M03-bus.md), [X01 §3.2](../cross-cutting/X01-transport.md) |
|
| 384 |
+
| Trace ring buffer endpoint | [X01 §3.2](../cross-cutting/X01-transport.md), [X03 §5](../cross-cutting/X03-observability.md) |
|
| 385 |
+
| Config | [X04](../cross-cutting/X04-config.md) |
|
| 386 |
+
|
| 387 |
+
---
|
| 388 |
+
|
| 389 |
+
## 10. Open questions
|
| 390 |
+
|
| 391 |
+
1. **Daemon mode on Linux** — `systemd` user unit? Ship one in packaging? Phase 1.5.
|
| 392 |
+
2. **Windows service / macOS LaunchAgent** — Phase 2.
|
| 393 |
+
3. **Shell completion** — Click supports it; ship completions for bash/zsh/fish.
|
| 394 |
+
4. **Progress bars for ingest / fetch** — `rich` progress; nice but optional.
|
docs/M13-onboarding.md
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# M13 — Onboarding
|
| 2 |
+
|
| 3 |
+
**Spec version:** v1.0
|
| 4 |
+
**Depends on:** M01 (identity), M08 (UI), X04 (config), X02 (events, to emit `community.*` events), `qrcode`, `Pillow`
|
| 5 |
+
**Depended on by:** First-run flow in `node.py`; entry point from M08 settings tab
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 1. Responsibility
|
| 10 |
+
|
| 11 |
+
The first time a user runs HearthNet, get them from "downloaded a binary" to "joined a community with a working node" in under two minutes. Specifically:
|
| 12 |
+
|
| 13 |
+
- Generate a device keypair if not present
|
| 14 |
+
- Offer two paths: **create** new community or **join** existing one
|
| 15 |
+
- Create flow: collect community name + policy, sign genesis manifest, display invite QR
|
| 16 |
+
- Join flow: scan / paste an invite, redeem it, emit `community.member.joined` event
|
| 17 |
+
- Optional: name the device, choose a profile (defaulted from hardware probe)
|
| 18 |
+
|
| 19 |
+
Out of scope:
|
| 20 |
+
- Federation between communities (Phase 2)
|
| 21 |
+
- Multiple-community membership on one device (Phase 2)
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## 2. File layout
|
| 26 |
+
|
| 27 |
+
```
|
| 28 |
+
hearthnet/ui/
|
| 29 |
+
└── onboarding.py # build_onboarding(), redeem_invite(), invite_to_qr()
|
| 30 |
+
|
| 31 |
+
hearthnet/identity/
|
| 32 |
+
└── manifest.py # build_community_manifest() — reused
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
A standalone `hearthnet init` CLI command in [M12](M12-cli.md) shares the same primitives.
|
| 36 |
+
|
| 37 |
+
---
|
| 38 |
+
|
| 39 |
+
## 3. Public API
|
| 40 |
+
|
| 41 |
+
### 3.1 `onboarding.py`
|
| 42 |
+
|
| 43 |
+
```python
|
| 44 |
+
# hearthnet/ui/onboarding.py
|
| 45 |
+
from dataclasses import dataclass
|
| 46 |
+
import qrcode
|
| 47 |
+
from io import BytesIO
|
| 48 |
+
|
| 49 |
+
@dataclass(frozen=True)
|
| 50 |
+
class InviteBlob:
|
| 51 |
+
"""The thing that travels between devices to enable joining."""
|
| 52 |
+
schema_version: int # 1
|
| 53 |
+
community_id: str # full
|
| 54 |
+
community_name: str # display
|
| 55 |
+
inviter_node_id: str # full
|
| 56 |
+
invitee_node_id: str # full of the device being invited
|
| 57 |
+
initial_level: str # "member" | "trusted"
|
| 58 |
+
bootstrap_endpoints: list[Endpoint] # how to reach the inviter
|
| 59 |
+
expires_at: str
|
| 60 |
+
signature: str # inviter's signature over canonical-JSON
|
| 61 |
+
|
| 62 |
+
# --- encoding ---
|
| 63 |
+
|
| 64 |
+
def encode_invite(blob: InviteBlob) -> str:
|
| 65 |
+
"""Compact representation suitable for QR / paste. Format:
|
| 66 |
+
'hearthnet://v1/<base64-url-nopad of canonical-JSON>'.
|
| 67 |
+
Aim: < 500 bytes (fits standard QR at error level M)."""
|
| 68 |
+
|
| 69 |
+
def decode_invite(text: str) -> InviteBlob:
|
| 70 |
+
"""Parse + verify signature. Raises OnboardingError on invalid."""
|
| 71 |
+
|
| 72 |
+
# --- QR ---
|
| 73 |
+
|
| 74 |
+
def invite_to_qr_png(blob: InviteBlob, *, box_size: int = 8) -> bytes:
|
| 75 |
+
"""Render the invite as a QR PNG. Used by UI for display."""
|
| 76 |
+
|
| 77 |
+
# --- create flow ---
|
| 78 |
+
|
| 79 |
+
def create_community(
|
| 80 |
+
name: str,
|
| 81 |
+
policy: CommunityPolicy,
|
| 82 |
+
kp: KeyPair,
|
| 83 |
+
state_dir: Path,
|
| 84 |
+
event_log: EventLog,
|
| 85 |
+
) -> CommunityManifest:
|
| 86 |
+
"""1. Build genesis community manifest (root = kp).
|
| 87 |
+
2. Persist manifest to <state_dir>/manifest.json.
|
| 88 |
+
3. Append community.created event to event_log.
|
| 89 |
+
4. Append community.member.invited + .joined for root device.
|
| 90 |
+
Returns the manifest."""
|
| 91 |
+
|
| 92 |
+
# --- join flow ---
|
| 93 |
+
|
| 94 |
+
def make_invite(
|
| 95 |
+
invitee_node_id_full: str,
|
| 96 |
+
inviter_kp: KeyPair,
|
| 97 |
+
community_manifest: CommunityManifest,
|
| 98 |
+
bootstrap_endpoints: list[Endpoint],
|
| 99 |
+
initial_level: str = "member",
|
| 100 |
+
ttl_seconds: int = 86400,
|
| 101 |
+
) -> InviteBlob:
|
| 102 |
+
"""Create + sign an invite blob. Also emit a community.member.invited event."""
|
| 103 |
+
|
| 104 |
+
def redeem_invite(
|
| 105 |
+
blob: InviteBlob,
|
| 106 |
+
our_kp: KeyPair,
|
| 107 |
+
transport_client: HttpClient,
|
| 108 |
+
event_log: EventLog,
|
| 109 |
+
) -> CommunityManifest:
|
| 110 |
+
"""1. Verify the invite signature, expiry, that invitee_node_id matches us.
|
| 111 |
+
2. Connect to one of bootstrap_endpoints; fetch /community/manifest.
|
| 112 |
+
3. Verify the community manifest's signature chain (root key in invite matches).
|
| 113 |
+
4. Run an initial X02 sync to populate the event log.
|
| 114 |
+
5. Emit a community.member.joined event (our authorship) including our node manifest.
|
| 115 |
+
6. Persist community manifest locally.
|
| 116 |
+
Returns the manifest. Raises OnboardingError on failure."""
|
| 117 |
+
|
| 118 |
+
# --- UI builders (Gradio components) ---
|
| 119 |
+
|
| 120 |
+
def build_onboarding(config: Config, kp_provider: Callable[[], KeyPair]) -> 'gr.Blocks':
|
| 121 |
+
"""Standalone Blocks UI for the first-run flow. Two-step wizard:
|
| 122 |
+
Step 1: 'Erstmal Schlüssel erzeugen' (auto, with progress)
|
| 123 |
+
Step 2: choice — create or join
|
| 124 |
+
- create: form (name, policy options) → preview manifest → confirm → show QR
|
| 125 |
+
- join: text area / camera upload for QR → preview invite → confirm → redeem
|
| 126 |
+
Returns the assembled Blocks. node.py mounts this BEFORE the main UI when
|
| 127 |
+
config.community.community_id is None."""
|
| 128 |
+
|
| 129 |
+
class OnboardingError(Exception):
|
| 130 |
+
"""code in {'invite_invalid','invite_expired','invitee_mismatch','bootstrap_unreachable',
|
| 131 |
+
'community_manifest_invalid','sync_failed','already_member'}"""
|
| 132 |
+
code: str
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
---
|
| 136 |
+
|
| 137 |
+
## 4. Flows in detail
|
| 138 |
+
|
| 139 |
+
### 4.1 Create-community flow
|
| 140 |
+
|
| 141 |
+
```
|
| 142 |
+
User clicks "Neue Community gründen"
|
| 143 |
+
↓
|
| 144 |
+
Form:
|
| 145 |
+
• Community name (free text, 1..64 chars)
|
| 146 |
+
• Allow new members to invite? (default true)
|
| 147 |
+
• Minimum signatures to revoke a member: 3 (advanced)
|
| 148 |
+
↓
|
| 149 |
+
On submit:
|
| 150 |
+
policy = CommunityPolicy(
|
| 151 |
+
min_signatures_to_invite = 1,
|
| 152 |
+
min_signatures_to_demote = 3,
|
| 153 |
+
min_signatures_to_revoke = 3,
|
| 154 |
+
capability_token_ttl_seconds = 86400,
|
| 155 |
+
federation_enabled = True,
|
| 156 |
+
default_member_can_invite = checkbox_value,
|
| 157 |
+
)
|
| 158 |
+
manifest = create_community(name, policy, our_kp, state_dir, event_log)
|
| 159 |
+
config.community.community_id = manifest.community_id
|
| 160 |
+
X04.save(config)
|
| 161 |
+
↓
|
| 162 |
+
"Du bist Gründer!" panel
|
| 163 |
+
• Show community short id
|
| 164 |
+
• Show your role: anchor
|
| 165 |
+
• Show QR code for inviting first member (preconfigured invite to ANY device)
|
| 166 |
+
→ actually: show a "create invite" button that asks for the invitee's NodeID
|
| 167 |
+
↓
|
| 168 |
+
Continue → main UI
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
### 4.2 Join-community flow
|
| 172 |
+
|
| 173 |
+
```
|
| 174 |
+
User clicks "Einer Community beitreten"
|
| 175 |
+
↓
|
| 176 |
+
"Wie hast du die Einladung erhalten?"
|
| 177 |
+
• Paste link / text
|
| 178 |
+
• Upload QR image
|
| 179 |
+
• Use camera (mobile only)
|
| 180 |
+
↓
|
| 181 |
+
decode_invite(text or scan) → InviteBlob
|
| 182 |
+
• verify signature
|
| 183 |
+
• check expiry
|
| 184 |
+
• check invitee_node_id == our_node_id_full
|
| 185 |
+
↓
|
| 186 |
+
Preview:
|
| 187 |
+
• Community name
|
| 188 |
+
• Inviter display name (lookup via bootstrap_endpoints[0]/manifest)
|
| 189 |
+
• Initial level: member
|
| 190 |
+
• "Beitreten" button
|
| 191 |
+
↓
|
| 192 |
+
On confirm:
|
| 193 |
+
redeem_invite(blob, our_kp, transport_client, event_log)
|
| 194 |
+
→ fetch community manifest from bootstrap
|
| 195 |
+
→ run X02 sync to fetch all history
|
| 196 |
+
→ emit community.member.joined event
|
| 197 |
+
→ persist community manifest
|
| 198 |
+
config.community.community_id = blob.community_id
|
| 199 |
+
X04.save(config)
|
| 200 |
+
↓
|
| 201 |
+
"Willkommen!" → main UI
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
### 4.3 Inviting someone (post-onboarding, from settings tab)
|
| 205 |
+
|
| 206 |
+
```
|
| 207 |
+
Settings → "Mitglied einladen"
|
| 208 |
+
↓
|
| 209 |
+
Two options:
|
| 210 |
+
(a) Generate invite for a specific NodeID
|
| 211 |
+
- user pastes invitee NodeID full form (they got it from their device)
|
| 212 |
+
- or scans a "I'm a fresh device" QR shown on their screen
|
| 213 |
+
(b) Use the in-person setup wizard (Phase 2: BLE pairing)
|
| 214 |
+
↓
|
| 215 |
+
make_invite(...) → InviteBlob
|
| 216 |
+
• Emit community.member.invited event (so the rest of the community knows)
|
| 217 |
+
• Display QR for the invitee to scan
|
| 218 |
+
• Expires in 24h
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
### 4.4 "I'm a fresh device" QR
|
| 222 |
+
|
| 223 |
+
Before joining, a freshly-installed device displays a QR encoding only its `node_id_full` (no signature; this is a public key). The inviter scans this QR with their existing device to build an invite. Format:
|
| 224 |
+
|
| 225 |
+
```
|
| 226 |
+
hearthnet-id://v1/<base64-url-nopad of {"node_id_full": "...", "display_name": "Hannes' Tablet"}>
|
| 227 |
+
```
|
| 228 |
+
|
| 229 |
+
This is unsigned because nothing private is in it. The inviter is the source of trust.
|
| 230 |
+
|
| 231 |
+
---
|
| 232 |
+
|
| 233 |
+
## 5. Behaviour
|
| 234 |
+
|
| 235 |
+
### 5.1 First-run detection
|
| 236 |
+
|
| 237 |
+
`node.py` checks at startup:
|
| 238 |
+
|
| 239 |
+
```python
|
| 240 |
+
if config.community.community_id is None:
|
| 241 |
+
# mount onboarding UI; don't start most services yet
|
| 242 |
+
...
|
| 243 |
+
else:
|
| 244 |
+
# normal startup
|
| 245 |
+
```
|
| 246 |
+
|
| 247 |
+
After the user completes onboarding, `node.py` continues with the full startup sequence (now with a valid community).
|
| 248 |
+
|
| 249 |
+
### 5.2 Re-onboarding (changing community)
|
| 250 |
+
|
| 251 |
+
The settings tab has a "Leave community" action. After confirm, the local data for that community is moved to `<DATA>/communities/<id>.archived/` and the user is sent back to onboarding. The user keeps the same keypair unless they explicitly choose to regenerate it (which then makes a new identity).
|
| 252 |
+
|
| 253 |
+
### 5.3 What we sign and what we don't
|
| 254 |
+
|
| 255 |
+
| Artifact | Signed by | Why |
|
| 256 |
+
|----------|-----------|-----|
|
| 257 |
+
| Invite blob | Inviter | So invitee can prove this came from a member |
|
| 258 |
+
| Genesis community manifest | Founder (root) | Establishes the root of trust |
|
| 259 |
+
| `community.member.joined` event | The joining device | Asserts "I am here, with this manifest" |
|
| 260 |
+
| Fresh-device ID QR | Nobody | It's just a public key; sees + uses |
|
| 261 |
+
|
| 262 |
+
### 5.4 Failure modes during join
|
| 263 |
+
|
| 264 |
+
- Bootstrap endpoints unreachable → `bootstrap_unreachable` with retry option
|
| 265 |
+
- Invite expired → `invite_expired`, must request a new one
|
| 266 |
+
- Invitee mismatch → `invitee_mismatch` (someone tried to redeem someone else's invite)
|
| 267 |
+
- Already a member → `already_member`, no-op with a message
|
| 268 |
+
|
| 269 |
+
### 5.5 Privacy of invites
|
| 270 |
+
|
| 271 |
+
An invite contains:
|
| 272 |
+
- Community ID, name
|
| 273 |
+
- Inviter NodeID
|
| 274 |
+
- Invitee NodeID
|
| 275 |
+
- Bootstrap endpoints
|
| 276 |
+
|
| 277 |
+
It does **not** contain the event log. Anyone seeing an invite knows the community exists and who is who, but not what was said in it.
|
| 278 |
+
|
| 279 |
+
---
|
| 280 |
+
|
| 281 |
+
## 6. Errors
|
| 282 |
+
|
| 283 |
+
`OnboardingError` codes:
|
| 284 |
+
|
| 285 |
+
- `invite_invalid` — malformed or bad signature
|
| 286 |
+
- `invite_expired` — past TTL
|
| 287 |
+
- `invitee_mismatch` — invite addressed to a different NodeID
|
| 288 |
+
- `bootstrap_unreachable` — can't reach inviter for manifest fetch
|
| 289 |
+
- `community_manifest_invalid` — fetched manifest fails verification
|
| 290 |
+
- `sync_failed` — initial event-log sync failed
|
| 291 |
+
- `already_member` — we're already in this community
|
| 292 |
+
|
| 293 |
+
---
|
| 294 |
+
|
| 295 |
+
## 7. Configuration
|
| 296 |
+
|
| 297 |
+
No new config keys. Uses `config.identity.*` and `config.community.*` from [X04](../cross-cutting/X04-config.md).
|
| 298 |
+
|
| 299 |
+
---
|
| 300 |
+
|
| 301 |
+
## 8. Tests
|
| 302 |
+
|
| 303 |
+
### Unit
|
| 304 |
+
- `test_invite_encode_decode_roundtrip`
|
| 305 |
+
- `test_invite_qr_under_500_bytes`
|
| 306 |
+
- `test_invite_expired_rejected`
|
| 307 |
+
- `test_invite_addressed_to_someone_else_rejected`
|
| 308 |
+
- `test_create_community_emits_three_events` (created + invited self + joined self)
|
| 309 |
+
- `test_redeem_invite_results_in_joined_event`
|
| 310 |
+
|
| 311 |
+
### Integration
|
| 312 |
+
- `test_two_node_join_flow_end_to_end` — node A creates community, generates invite, node B redeems, both see each other as members
|
| 313 |
+
- `test_join_during_partial_partition` — bootstrap unreachable, retry succeeds
|
| 314 |
+
- `test_redeem_then_immediately_sync_marketplace` — historical posts visible after sync
|
| 315 |
+
|
| 316 |
+
---
|
| 317 |
+
|
| 318 |
+
## 9. Cross-references
|
| 319 |
+
|
| 320 |
+
| What | Where |
|
| 321 |
+
|------|-------|
|
| 322 |
+
| Community manifest schema | [CONTRACT §6.2](../CAPABILITY_CONTRACT.md) |
|
| 323 |
+
| `community.*` events | [CONTRACT §7.2](../CAPABILITY_CONTRACT.md) |
|
| 324 |
+
| Identity primitives | [M01](M01-identity.md) |
|
| 325 |
+
| UI integration | [M08 §8.5](M08-ui.md) |
|
| 326 |
+
| CLI `hearthnet init` | [M12 §3](M12-cli.md) |
|
| 327 |
+
| Event log + sync | [X02](../cross-cutting/X02-events.md) |
|
| 328 |
+
|
| 329 |
+
---
|
| 330 |
+
|
| 331 |
+
## 10. Open questions
|
| 332 |
+
|
| 333 |
+
1. **Multi-community membership** — out of scope MVP. Phase 2: same keypair, multiple `<DATA>/communities/<id>/` dirs.
|
| 334 |
+
2. **Recovery if device key lost** — currently impossible (key = identity). Phase 2: "social recovery" via 2-of-3 trusted members re-issuing a re-joined identity.
|
| 335 |
+
3. **BLE pairing** — Phase 2; faster than QR for adjacent devices.
|
| 336 |
+
4. **Camera capture on mobile web** — `getUserMedia` is available; needs HTTPS. Self-signed cert may trip browsers; document workaround.
|
docs/X01-transport.md
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# X01 — Transport
|
| 2 |
+
|
| 3 |
+
**Spec version:** v1.0
|
| 4 |
+
**Depends on:** M01 (identity), X04 (config), X03 (observability), FastAPI, uvicorn, httpx
|
| 5 |
+
**Depended on by:** M03 (bus), M02 (discovery, indirectly), X02 (sync endpoints)
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 1. Responsibility
|
| 10 |
+
|
| 11 |
+
Moves bytes between nodes over HTTP/1.1+TLS. Provides:
|
| 12 |
+
|
| 13 |
+
- An HTTP server hosting the bus, pubsub, sync, and metrics endpoints
|
| 14 |
+
- An HTTP client with TLS pinning and signature signing
|
| 15 |
+
- Server-Sent Events (SSE) for streaming
|
| 16 |
+
- Backpressure on streams (window-based)
|
| 17 |
+
- Rate limiting per (peer, capability)
|
| 18 |
+
- Self-signed TLS cert generation and pinning
|
| 19 |
+
|
| 20 |
+
This module knows nothing about *what* it transports. It dispatches calls to the bus, which routes to services.
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
## 2. File layout
|
| 25 |
+
|
| 26 |
+
```
|
| 27 |
+
hearthnet/transport/
|
| 28 |
+
├── __init__.py
|
| 29 |
+
├── server.py # FastAPI app factory + lifecycle
|
| 30 |
+
├── client.py # HttpClient: signed requests, TLS pinning
|
| 31 |
+
├── streams.py # SSE writer/reader, frame parsing
|
| 32 |
+
├── backpressure.py # Window-based flow control
|
| 33 |
+
└── tls.py # Cert load/generate, peer pinning store
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
+
## 3. HTTP server
|
| 39 |
+
|
| 40 |
+
### 3.1 Public API
|
| 41 |
+
|
| 42 |
+
```python
|
| 43 |
+
# hearthnet/transport/server.py
|
| 44 |
+
from fastapi import FastAPI
|
| 45 |
+
from typing import Awaitable, Callable
|
| 46 |
+
|
| 47 |
+
class HttpServer:
|
| 48 |
+
def __init__(
|
| 49 |
+
self,
|
| 50 |
+
config: TransportConfig,
|
| 51 |
+
kp: KeyPair,
|
| 52 |
+
bus: 'CapabilityBus',
|
| 53 |
+
event_sync: 'SyncServer',
|
| 54 |
+
community_manifest_provider: Callable[[], CommunityManifest],
|
| 55 |
+
):
|
| 56 |
+
...
|
| 57 |
+
|
| 58 |
+
def app(self) -> FastAPI:
|
| 59 |
+
"""The configured FastAPI app. Used by tests."""
|
| 60 |
+
|
| 61 |
+
async def run(self) -> None:
|
| 62 |
+
"""Block, serving until cancelled."""
|
| 63 |
+
|
| 64 |
+
async def shutdown(self) -> None: ...
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
### 3.2 Endpoints (mounted)
|
| 68 |
+
|
| 69 |
+
| Route | Method | Purpose | Handler |
|
| 70 |
+
|-------|--------|---------|---------|
|
| 71 |
+
| `/bus/v1/call` | POST | Capability call (sync or stream) | bus dispatch |
|
| 72 |
+
| `/manifest` | GET | Current node manifest JSON | identity |
|
| 73 |
+
| `/community/manifest` | GET | Current community manifest | identity |
|
| 74 |
+
| `/sync/v1/heads` | GET | Sync heads | X02 |
|
| 75 |
+
| `/sync/v1/events` | POST | Sync delta | X02 |
|
| 76 |
+
| `/pubsub/v1/subscribe` | GET (long-poll) | Topic subscription | bus pubsub |
|
| 77 |
+
| `/health` | GET | Liveness | observability |
|
| 78 |
+
| `/ready` | GET | Readiness (≥1 capability + ≥1 peer) | observability |
|
| 79 |
+
| `/metrics` | GET | Prometheus | observability |
|
| 80 |
+
| `/trace/recent` | GET | Last N traces (JSON) | observability |
|
| 81 |
+
|
| 82 |
+
`/mobile/*` for the mobile web client is mounted by [M08](../modules/M08-ui.md) outside transport's concern.
|
| 83 |
+
|
| 84 |
+
### 3.3 Request lifecycle
|
| 85 |
+
|
| 86 |
+
```
|
| 87 |
+
HTTP request arrives
|
| 88 |
+
↓
|
| 89 |
+
extract X-HearthNet-* headers
|
| 90 |
+
↓
|
| 91 |
+
verify signature (M01) using X-HearthNet-From
|
| 92 |
+
↓
|
| 93 |
+
check author is a community member (community manifest)
|
| 94 |
+
↓
|
| 95 |
+
attach trace (X03) from X-HearthNet-Request-Id
|
| 96 |
+
↓
|
| 97 |
+
rate-limit check (this module)
|
| 98 |
+
↓
|
| 99 |
+
dispatch to bus.handle_call(capability, version, body, caller)
|
| 100 |
+
↓
|
| 101 |
+
return response (or stream)
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
Failures at each stage emit a typed error matching [CONTRACT §9](../CAPABILITY_CONTRACT.md).
|
| 105 |
+
|
| 106 |
+
---
|
| 107 |
+
|
| 108 |
+
## 4. TLS
|
| 109 |
+
|
| 110 |
+
### 4.1 Cert generation
|
| 111 |
+
|
| 112 |
+
On first run, generate a self-signed X.509 certificate with:
|
| 113 |
+
|
| 114 |
+
- Subject CN = `<short_node_id>.hearthnet.local`
|
| 115 |
+
- SAN = `IP:<config.transport.host>` if not 0.0.0.0, else all interfaces
|
| 116 |
+
- Public key derived from the device Ed25519 key (Ed25519 is a TLS 1.3 signature algorithm)
|
| 117 |
+
- Valid for 10 years (covers normal device life)
|
| 118 |
+
|
| 119 |
+
Cert + key persisted at `<DATA>/tls/server.crt` and `<DATA>/tls/server.key`.
|
| 120 |
+
|
| 121 |
+
### 4.2 Pinning store
|
| 122 |
+
|
| 123 |
+
```python
|
| 124 |
+
# hearthnet/transport/tls.py
|
| 125 |
+
class PinnedCerts:
|
| 126 |
+
"""Stores the first-seen TLS cert fingerprint per NodeID.
|
| 127 |
+
Mismatches on subsequent connections raise a warning and refuse the connection."""
|
| 128 |
+
|
| 129 |
+
def __init__(self, db_path: Path): ...
|
| 130 |
+
def record(self, node_id: str, fingerprint: bytes) -> None: ...
|
| 131 |
+
def expected(self, node_id: str) -> bytes | None: ...
|
| 132 |
+
def verify(self, node_id: str, presented: bytes) -> bool: ...
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
### 4.3 Hostname verification
|
| 136 |
+
|
| 137 |
+
Disabled. We pin to NodeID, not DNS. Peers are referenced by IP+port from manifests.
|
| 138 |
+
|
| 139 |
+
---
|
| 140 |
+
|
| 141 |
+
## 5. HTTP client
|
| 142 |
+
|
| 143 |
+
### 5.1 Public API
|
| 144 |
+
|
| 145 |
+
```python
|
| 146 |
+
# hearthnet/transport/client.py
|
| 147 |
+
class HttpClient:
|
| 148 |
+
def __init__(
|
| 149 |
+
self,
|
| 150 |
+
kp: KeyPair,
|
| 151 |
+
node_id: str,
|
| 152 |
+
community_id: str,
|
| 153 |
+
pinned_certs: PinnedCerts,
|
| 154 |
+
timeout_default_seconds: float = RPC_DEFAULT_TIMEOUT_SECONDS,
|
| 155 |
+
):
|
| 156 |
+
...
|
| 157 |
+
|
| 158 |
+
async def call(
|
| 159 |
+
self,
|
| 160 |
+
peer: Endpoint,
|
| 161 |
+
capability: str,
|
| 162 |
+
version: str,
|
| 163 |
+
body: dict,
|
| 164 |
+
*,
|
| 165 |
+
trace_id: str | None = None,
|
| 166 |
+
timeout_seconds: float | None = None,
|
| 167 |
+
) -> dict:
|
| 168 |
+
"""Sync RPC. Signs request, opens TLS connection (or reuses pinned),
|
| 169 |
+
sends, awaits response, verifies response signature if present,
|
| 170 |
+
returns body. Raises CallError on transport / protocol failure."""
|
| 171 |
+
|
| 172 |
+
async def stream(
|
| 173 |
+
self,
|
| 174 |
+
peer: Endpoint,
|
| 175 |
+
capability: str,
|
| 176 |
+
version: str,
|
| 177 |
+
body: dict,
|
| 178 |
+
*,
|
| 179 |
+
trace_id: str | None = None,
|
| 180 |
+
cancel: asyncio.Event | None = None,
|
| 181 |
+
) -> AsyncIterator[Frame]:
|
| 182 |
+
"""Open SSE stream. Yields Frame objects (event_name + data dict).
|
| 183 |
+
Honours backpressure: sends ACK frames automatically.
|
| 184 |
+
On cancel: closes connection, server aborts within 200ms."""
|
| 185 |
+
|
| 186 |
+
async def close(self) -> None: ...
|
| 187 |
+
|
| 188 |
+
class CallError(Exception):
|
| 189 |
+
code: ErrorCode
|
| 190 |
+
message: str
|
| 191 |
+
retry_after_ms: int | None
|
| 192 |
+
alt_capabilities: list[str]
|
| 193 |
+
alt_nodes: list[str]
|
| 194 |
+
```
|
| 195 |
+
|
| 196 |
+
### 5.2 Connection management
|
| 197 |
+
|
| 198 |
+
- One `httpx.AsyncClient` per peer, reused across calls
|
| 199 |
+
- Idle timeout `CONNECTION_IDLE_SECONDS` (60s), then close
|
| 200 |
+
- On disconnect, lazy reconnect on next call
|
| 201 |
+
- Reconnect backoff: exponential, cap `RECONNECT_BACKOFF_CAP_SECONDS` (30s)
|
| 202 |
+
|
| 203 |
+
### 5.3 Signing
|
| 204 |
+
|
| 205 |
+
For every outbound request, the client constructs:
|
| 206 |
+
|
| 207 |
+
```python
|
| 208 |
+
envelope = {
|
| 209 |
+
"capability": capability,
|
| 210 |
+
"version": version,
|
| 211 |
+
"request_id": trace_id or new_ulid(),
|
| 212 |
+
"from": node_id_full,
|
| 213 |
+
"community": community_id,
|
| 214 |
+
"timestamp": rfc3339_now(),
|
| 215 |
+
"body": body,
|
| 216 |
+
}
|
| 217 |
+
sig = kp.sign_bytes(canonical_json(envelope))
|
| 218 |
+
```
|
| 219 |
+
|
| 220 |
+
Headers set: `X-HearthNet-Capability`, `-Version`, `-Request-Id`, `-From`, `-Community`, `-Timestamp`, `-Signature`.
|
| 221 |
+
|
| 222 |
+
---
|
| 223 |
+
|
| 224 |
+
## 6. Streaming
|
| 225 |
+
|
| 226 |
+
### 6.1 SSE writer (server)
|
| 227 |
+
|
| 228 |
+
```python
|
| 229 |
+
# hearthnet/transport/streams.py
|
| 230 |
+
class SseWriter:
|
| 231 |
+
def __init__(self, response: StreamingResponse): ...
|
| 232 |
+
async def emit(self, event: str, data: dict) -> None: ...
|
| 233 |
+
async def emit_token(self, token: dict) -> None: ... # convenience
|
| 234 |
+
async def emit_progress(self, current: int, total: int, stage: str) -> None: ...
|
| 235 |
+
async def emit_error(self, code: ErrorCode, **kwargs) -> None: ...
|
| 236 |
+
async def emit_done(self, **meta) -> None: ...
|
| 237 |
+
async def emit_ack(self, upto: int) -> None: ...
|
| 238 |
+
@property
|
| 239 |
+
def cancelled(self) -> bool: ...
|
| 240 |
+
```
|
| 241 |
+
|
| 242 |
+
### 6.2 SSE reader (client)
|
| 243 |
+
|
| 244 |
+
```python
|
| 245 |
+
class SseReader:
|
| 246 |
+
async def __aiter__(self) -> AsyncIterator[Frame]: ...
|
| 247 |
+
async def cancel(self) -> None: ...
|
| 248 |
+
|
| 249 |
+
@dataclass(frozen=True)
|
| 250 |
+
class Frame:
|
| 251 |
+
event: str # "token", "chunk", "progress", "ack", "done", "error", "manifest", "ready", ...
|
| 252 |
+
data: dict
|
| 253 |
+
seq: int # local sequence number for backpressure
|
| 254 |
+
```
|
| 255 |
+
|
| 256 |
+
### 6.3 Backpressure
|
| 257 |
+
|
| 258 |
+
```python
|
| 259 |
+
# hearthnet/transport/backpressure.py
|
| 260 |
+
class FlowControl:
|
| 261 |
+
"""Window-based flow control for one stream."""
|
| 262 |
+
def __init__(self, window: int = STREAM_WINDOW_FRAMES, ack_interval: int = STREAM_ACK_INTERVAL_FRAMES):
|
| 263 |
+
...
|
| 264 |
+
|
| 265 |
+
@property
|
| 266 |
+
def window_used(self) -> int: ...
|
| 267 |
+
def send(self) -> None: ... # call before emitting a frame; blocks (await) when full
|
| 268 |
+
def ack(self, upto: int) -> None: ...
|
| 269 |
+
|
| 270 |
+
@property
|
| 271 |
+
def needs_ack(self) -> bool: ... # reader checks this; emits ack frame
|
| 272 |
+
```
|
| 273 |
+
|
| 274 |
+
### 6.4 Cancellation
|
| 275 |
+
|
| 276 |
+
- Client closes the HTTP connection
|
| 277 |
+
- Server's request task is cancelled
|
| 278 |
+
- Service handler's generator receives `GeneratorExit` (or async equivalent)
|
| 279 |
+
- Service emits final telemetry and exits within 200ms
|
| 280 |
+
- A finally block guarantees resources are freed
|
| 281 |
+
|
| 282 |
+
---
|
| 283 |
+
|
| 284 |
+
## 7. Rate limiting
|
| 285 |
+
|
| 286 |
+
```python
|
| 287 |
+
# hearthnet/transport/__init__.py (or rate_limit.py)
|
| 288 |
+
class RateLimiter:
|
| 289 |
+
"""Token-bucket per (peer, capability) and per peer total."""
|
| 290 |
+
def __init__(self, config: TransportConfig): ...
|
| 291 |
+
|
| 292 |
+
def check(self, peer_node_id: str, capability: str) -> RateCheck:
|
| 293 |
+
"""Returns ok, soft-limited, or hard-limited.
|
| 294 |
+
Soft → return 429 with retry_after_ms
|
| 295 |
+
Hard → drop without response (logged + counter)."""
|
| 296 |
+
|
| 297 |
+
@dataclass(frozen=True)
|
| 298 |
+
class RateCheck:
|
| 299 |
+
allowed: bool
|
| 300 |
+
soft_exceeded: bool
|
| 301 |
+
retry_after_ms: int
|
| 302 |
+
```
|
| 303 |
+
|
| 304 |
+
Limits from constants:
|
| 305 |
+
- `RATE_LIMIT_SOFT_RPS_PER_CAP = 10`
|
| 306 |
+
- `RATE_LIMIT_HARD_RPS_PER_CAP = 100`
|
| 307 |
+
- `RATE_LIMIT_SOFT_RPS_TOTAL = 100`
|
| 308 |
+
- `RATE_LIMIT_HARD_RPS_TOTAL = 1000`
|
| 309 |
+
|
| 310 |
+
---
|
| 311 |
+
|
| 312 |
+
## 8. Pub-sub (long-poll)
|
| 313 |
+
|
| 314 |
+
```python
|
| 315 |
+
# hearthnet/transport/server.py (sub-component)
|
| 316 |
+
class PubSubServer:
|
| 317 |
+
"""In-memory topic registry; long-poll subscribers."""
|
| 318 |
+
|
| 319 |
+
async def publish(self, topic: str, payload: dict) -> None: ...
|
| 320 |
+
|
| 321 |
+
async def subscribe(self, topic: str, *, last_seq: int = 0, timeout_seconds: float = 30) -> dict:
|
| 322 |
+
"""Long-poll: returns next message or {timeout: true} after timeout_seconds."""
|
| 323 |
+
```
|
| 324 |
+
|
| 325 |
+
`/pubsub/v1/subscribe?topic=marketplace.post.created&last_seq=0&timeout=30`
|
| 326 |
+
|
| 327 |
+
WebSocket variant deferred to Phase 2.
|
| 328 |
+
|
| 329 |
+
---
|
| 330 |
+
|
| 331 |
+
## 9. Errors
|
| 332 |
+
|
| 333 |
+
Server returns errors in the format of [CONTRACT §5.4](../CAPABILITY_CONTRACT.md). Client raises `CallError` carrying the same code.
|
| 334 |
+
|
| 335 |
+
---
|
| 336 |
+
|
| 337 |
+
## 10. Configuration
|
| 338 |
+
|
| 339 |
+
From [X04](X04-config.md):
|
| 340 |
+
|
| 341 |
+
```python
|
| 342 |
+
config.transport.host
|
| 343 |
+
config.transport.port
|
| 344 |
+
config.transport.tls_cert # optional override
|
| 345 |
+
config.transport.tls_key # optional override
|
| 346 |
+
```
|
| 347 |
+
|
| 348 |
+
Constants: `STREAM_WINDOW_FRAMES`, `STREAM_ACK_INTERVAL_FRAMES`, `STREAM_ACK_TIMEOUT_SECONDS`, `RPC_DEFAULT_TIMEOUT_SECONDS`, `CONNECTION_IDLE_SECONDS`, `RECONNECT_BACKOFF_CAP_SECONDS`, `RATE_LIMIT_*`.
|
| 349 |
+
|
| 350 |
+
---
|
| 351 |
+
|
| 352 |
+
## 11. Tests
|
| 353 |
+
|
| 354 |
+
### Unit
|
| 355 |
+
- `test_request_signing_roundtrip` — sign on client, verify on server
|
| 356 |
+
- `test_envelope_canonicalisation` — same input → same signature
|
| 357 |
+
- `test_sse_frame_format` — parses both ways
|
| 358 |
+
- `test_flow_control_blocks_when_full` — `send()` awaits until ack arrives
|
| 359 |
+
- `test_rate_limit_soft_then_hard`
|
| 360 |
+
- `test_tls_pinning_first_seen_then_mismatch`
|
| 361 |
+
|
| 362 |
+
### Integration
|
| 363 |
+
- `test_two_node_call_roundtrip`
|
| 364 |
+
- `test_stream_with_cancellation`
|
| 365 |
+
- `test_concurrent_streams_share_connection`
|
| 366 |
+
- `test_chaos_packet_loss_30pct` (using `tc`)
|
| 367 |
+
|
| 368 |
+
---
|
| 369 |
+
|
| 370 |
+
## 12. Cross-references
|
| 371 |
+
|
| 372 |
+
| What | Where |
|
| 373 |
+
|------|-------|
|
| 374 |
+
| Wire format | [CONTRACT §5](../CAPABILITY_CONTRACT.md) |
|
| 375 |
+
| Signing rules | [CONTRACT §10](../CAPABILITY_CONTRACT.md), [M01 §3.1](../modules/M01-identity.md) |
|
| 376 |
+
| Bus dispatch | [M03](../modules/M03-bus.md) |
|
| 377 |
+
| Sync endpoints | [X02 §6](X02-events.md) |
|
| 378 |
+
| Pub-sub topics | [CONTRACT §8](../CAPABILITY_CONTRACT.md) |
|
| 379 |
+
| Mobile UI mount point | [M08](../modules/M08-ui.md) |
|
docs/X02-events.md
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# X02 — Events
|
| 2 |
+
|
| 3 |
+
**Spec version:** v1.0
|
| 4 |
+
**Depends on:** M01 (identity), X04 (config), X03 (observability), SQLite (stdlib)
|
| 5 |
+
**Depended on by:** M06 (marketplace), M10 (chat), M03 (bus indirectly), M01 (community manifest regeneration)
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 1. Responsibility
|
| 10 |
+
|
| 11 |
+
The community-wide append-only event log. Provides:
|
| 12 |
+
|
| 13 |
+
- Durable storage of signed events
|
| 14 |
+
- Lamport clock management
|
| 15 |
+
- Replay into materialised views
|
| 16 |
+
- Snapshots for fast bootstrap
|
| 17 |
+
- Gossip sync between peers
|
| 18 |
+
|
| 19 |
+
This module knows nothing about *what* an event means semantically (that is each consuming module's concern). It enforces only ordering, signing, and durability.
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
## 2. File layout
|
| 24 |
+
|
| 25 |
+
```
|
| 26 |
+
hearthnet/events/
|
| 27 |
+
├── __init__.py
|
| 28 |
+
├── log.py # EventLog: SQLite append + read
|
| 29 |
+
├── lamport.py # LamportClock
|
| 30 |
+
├── types.py # Event dataclass + canonical event_type strings
|
| 31 |
+
├── replay.py # Replay engine for materialised views
|
| 32 |
+
├── snapshot.py # SnapshotStore: write/read signed snapshots
|
| 33 |
+
└── sync.py # Gossip sync protocol (heads exchange + delta push)
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
+
## 3. Public API
|
| 39 |
+
|
| 40 |
+
### 3.1 `types.py`
|
| 41 |
+
|
| 42 |
+
```python
|
| 43 |
+
# hearthnet/events/types.py
|
| 44 |
+
from dataclasses import dataclass
|
| 45 |
+
from typing import Any, Literal
|
| 46 |
+
|
| 47 |
+
EventType = Literal[
|
| 48 |
+
"community.created",
|
| 49 |
+
"community.member.invited",
|
| 50 |
+
"community.member.joined",
|
| 51 |
+
"community.member.revoked",
|
| 52 |
+
"community.member.promoted",
|
| 53 |
+
"community.member.demoted",
|
| 54 |
+
"community.policy.updated",
|
| 55 |
+
"node.manifest.updated",
|
| 56 |
+
"market.post.created",
|
| 57 |
+
"market.post.updated",
|
| 58 |
+
"market.post.expired",
|
| 59 |
+
"chat.message.sent",
|
| 60 |
+
"chat.message.delivered",
|
| 61 |
+
"chat.message.read",
|
| 62 |
+
"file.cid.advertised",
|
| 63 |
+
"file.cid.unpinned",
|
| 64 |
+
"rag.document.ingested",
|
| 65 |
+
"federation.peer.added", # reserved
|
| 66 |
+
"federation.peer.removed", # reserved
|
| 67 |
+
]
|
| 68 |
+
|
| 69 |
+
@dataclass(frozen=True)
|
| 70 |
+
class Event:
|
| 71 |
+
schema_version: int # always 1 in this release
|
| 72 |
+
event_id: str # ULID
|
| 73 |
+
lamport: int
|
| 74 |
+
wall_clock: str # RFC 3339
|
| 75 |
+
community_id: str
|
| 76 |
+
author: str # NodeID full form
|
| 77 |
+
event_type: EventType
|
| 78 |
+
data: dict[str, Any]
|
| 79 |
+
signature: str
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
### 3.2 `lamport.py`
|
| 83 |
+
|
| 84 |
+
```python
|
| 85 |
+
# hearthnet/events/lamport.py
|
| 86 |
+
class LamportClock:
|
| 87 |
+
"""In-memory + persisted Lamport clock for one community.
|
| 88 |
+
|
| 89 |
+
On every send: next = ++current
|
| 90 |
+
On every receive: current = max(current, received) + 1
|
| 91 |
+
Persisted to SQLite on every change (atomic update inside event insert tx).
|
| 92 |
+
"""
|
| 93 |
+
|
| 94 |
+
def __init__(self, conn: sqlite3.Connection, community_id: str):
|
| 95 |
+
"""Loads the current value from the events table head."""
|
| 96 |
+
|
| 97 |
+
@property
|
| 98 |
+
def current(self) -> int: ...
|
| 99 |
+
|
| 100 |
+
def tick_for_send(self) -> int:
|
| 101 |
+
"""Returns the next Lamport value AND persists it. Idempotent within a tx."""
|
| 102 |
+
|
| 103 |
+
def observe(self, received_lamport: int) -> None:
|
| 104 |
+
"""Update on receive."""
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
### 3.3 `log.py`
|
| 108 |
+
|
| 109 |
+
```python
|
| 110 |
+
# hearthnet/events/log.py
|
| 111 |
+
class EventLog:
|
| 112 |
+
"""SQLite-backed append-only event log for one community."""
|
| 113 |
+
|
| 114 |
+
def __init__(self, db_path: Path, community_id: str):
|
| 115 |
+
"""Open or create the DB. Creates schema if absent."""
|
| 116 |
+
|
| 117 |
+
# -- writing --
|
| 118 |
+
|
| 119 |
+
def append_local(self, event_type: EventType, data: dict, author_kp: KeyPair) -> Event:
|
| 120 |
+
"""Mint a new local event: tick Lamport, sign, persist, emit pubsub.
|
| 121 |
+
Returns the persisted Event."""
|
| 122 |
+
|
| 123 |
+
def append_received(self, event: Event) -> bool:
|
| 124 |
+
"""Persist an event received from a peer.
|
| 125 |
+
Returns True if new, False if duplicate.
|
| 126 |
+
Verifies signature and ordering; raises EventLogError on invalid."""
|
| 127 |
+
|
| 128 |
+
# -- reading --
|
| 129 |
+
|
| 130 |
+
def head(self) -> int:
|
| 131 |
+
"""Highest lamport in this community."""
|
| 132 |
+
|
| 133 |
+
def get(self, event_id: str) -> Event | None: ...
|
| 134 |
+
|
| 135 |
+
def replay(
|
| 136 |
+
self,
|
| 137 |
+
*,
|
| 138 |
+
since_lamport: int = 0,
|
| 139 |
+
event_types: list[EventType] | None = None,
|
| 140 |
+
limit: int | None = None,
|
| 141 |
+
) -> Iterator[Event]:
|
| 142 |
+
"""Yield events in (lamport, event_id) order."""
|
| 143 |
+
|
| 144 |
+
def heads_by_type(self) -> dict[EventType, int]:
|
| 145 |
+
"""Highest lamport per event_type. Used by sync."""
|
| 146 |
+
|
| 147 |
+
# -- pubsub fanout (in-process subscribers only) --
|
| 148 |
+
|
| 149 |
+
def subscribe(self, event_types: list[EventType] | None = None) -> AsyncIterator[Event]: ...
|
| 150 |
+
|
| 151 |
+
class EventLogError(Exception):
|
| 152 |
+
"""code in {'invalid_signature','out_of_order','unknown_author','revoked_author','schema_unknown','db_corrupt'}"""
|
| 153 |
+
code: str
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
### 3.4 `replay.py`
|
| 157 |
+
|
| 158 |
+
```python
|
| 159 |
+
# hearthnet/events/replay.py
|
| 160 |
+
class MaterialisedView(Protocol):
|
| 161 |
+
"""Each consuming module implements this to derive its state from the event stream."""
|
| 162 |
+
def reset(self) -> None: ...
|
| 163 |
+
def apply(self, event: Event) -> None: ...
|
| 164 |
+
def snapshot_state(self) -> dict: ...
|
| 165 |
+
def restore_state(self, state: dict) -> None: ...
|
| 166 |
+
|
| 167 |
+
class ReplayEngine:
|
| 168 |
+
def __init__(self, log: EventLog):
|
| 169 |
+
self.log = log
|
| 170 |
+
self.views: dict[str, MaterialisedView] = {}
|
| 171 |
+
|
| 172 |
+
def register(self, name: str, view: MaterialisedView, event_types: list[EventType]) -> None:
|
| 173 |
+
"""Register a view for replay."""
|
| 174 |
+
|
| 175 |
+
def rebuild(self, view_name: str, from_lamport: int = 0) -> None:
|
| 176 |
+
"""Reset view, replay all relevant events from `from_lamport`."""
|
| 177 |
+
|
| 178 |
+
def rebuild_all(self) -> None: ...
|
| 179 |
+
|
| 180 |
+
def on_event(self, event: Event) -> None:
|
| 181 |
+
"""Apply a single new event to all subscribed views. Called by EventLog.append_*."""
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
### 3.5 `snapshot.py`
|
| 185 |
+
|
| 186 |
+
```python
|
| 187 |
+
# hearthnet/events/snapshot.py
|
| 188 |
+
@dataclass(frozen=True)
|
| 189 |
+
class Snapshot:
|
| 190 |
+
schema_version: int
|
| 191 |
+
community_id: str
|
| 192 |
+
lamport: int
|
| 193 |
+
wall_clock: str
|
| 194 |
+
state: dict # union of all view snapshot_state() results
|
| 195 |
+
covers_events_up_to: int
|
| 196 |
+
signature: str
|
| 197 |
+
|
| 198 |
+
class SnapshotStore:
|
| 199 |
+
def __init__(self, dir_path: Path, community_id: str):
|
| 200 |
+
"""<DATA>/communities/<id>/snapshots/"""
|
| 201 |
+
|
| 202 |
+
def latest(self) -> Snapshot | None: ...
|
| 203 |
+
|
| 204 |
+
def write(self, snap: Snapshot) -> None:
|
| 205 |
+
"""Write atomically; filename = `<lamport>.bin`."""
|
| 206 |
+
|
| 207 |
+
def list(self) -> list[int]:
|
| 208 |
+
"""Lamports of all snapshots on disk, ascending."""
|
| 209 |
+
|
| 210 |
+
def prune(self, keep_last_n: int = 7) -> None: ...
|
| 211 |
+
|
| 212 |
+
def build_snapshot(
|
| 213 |
+
log: EventLog,
|
| 214 |
+
engine: ReplayEngine,
|
| 215 |
+
signing_kp: KeyPair,
|
| 216 |
+
at_lamport: int | None = None,
|
| 217 |
+
) -> Snapshot:
|
| 218 |
+
"""Walk views, collect snapshot_state(), sign. If at_lamport is None,
|
| 219 |
+
use head - SNAPSHOT_LAG_LAMPORT."""
|
| 220 |
+
|
| 221 |
+
def restore_from_snapshot(snap: Snapshot, engine: ReplayEngine, log: EventLog) -> None:
|
| 222 |
+
"""Verify signature; for each view, call restore_state(); then replay events
|
| 223 |
+
from snap.covers_events_up_to+1 forward to log.head()."""
|
| 224 |
+
```
|
| 225 |
+
|
| 226 |
+
### 3.6 `sync.py`
|
| 227 |
+
|
| 228 |
+
```python
|
| 229 |
+
# hearthnet/events/sync.py
|
| 230 |
+
@dataclass(frozen=True)
|
| 231 |
+
class HeadsReport:
|
| 232 |
+
community_id: str
|
| 233 |
+
heads_by_type: dict[EventType, int]
|
| 234 |
+
head: int
|
| 235 |
+
|
| 236 |
+
class SyncClient:
|
| 237 |
+
"""Initiated by one node against a peer."""
|
| 238 |
+
def __init__(self, log: EventLog, transport_client: HttpClient):
|
| 239 |
+
...
|
| 240 |
+
|
| 241 |
+
async def sync_with(self, peer_endpoint: Endpoint) -> SyncResult:
|
| 242 |
+
"""1) GET /sync/v1/heads → HeadsReport
|
| 243 |
+
2) compute missing → POST /sync/v1/events with our missing
|
| 244 |
+
3) receive their missing
|
| 245 |
+
4) apply, returns counts"""
|
| 246 |
+
|
| 247 |
+
class SyncServer:
|
| 248 |
+
"""Bound to the bus transport; exposes /sync/v1/heads and /sync/v1/events."""
|
| 249 |
+
def __init__(self, log: EventLog):
|
| 250 |
+
...
|
| 251 |
+
async def serve_heads(self) -> HeadsReport: ...
|
| 252 |
+
async def serve_events(self, events: list[Event]) -> dict: ...
|
| 253 |
+
|
| 254 |
+
@dataclass(frozen=True)
|
| 255 |
+
class SyncResult:
|
| 256 |
+
sent_count: int
|
| 257 |
+
received_count: int
|
| 258 |
+
duration_ms: int
|
| 259 |
+
```
|
| 260 |
+
|
| 261 |
+
---
|
| 262 |
+
|
| 263 |
+
## 4. SQLite schema
|
| 264 |
+
|
| 265 |
+
```sql
|
| 266 |
+
PRAGMA journal_mode = WAL;
|
| 267 |
+
PRAGMA synchronous = NORMAL;
|
| 268 |
+
|
| 269 |
+
CREATE TABLE IF NOT EXISTS events (
|
| 270 |
+
event_id TEXT PRIMARY KEY, -- ULID
|
| 271 |
+
community_id TEXT NOT NULL,
|
| 272 |
+
schema_version INTEGER NOT NULL DEFAULT 1,
|
| 273 |
+
lamport INTEGER NOT NULL,
|
| 274 |
+
wall_clock TEXT NOT NULL,
|
| 275 |
+
author TEXT NOT NULL,
|
| 276 |
+
event_type TEXT NOT NULL,
|
| 277 |
+
data TEXT NOT NULL, -- JSON
|
| 278 |
+
signature TEXT NOT NULL,
|
| 279 |
+
received_at TEXT NOT NULL
|
| 280 |
+
);
|
| 281 |
+
|
| 282 |
+
CREATE INDEX IF NOT EXISTS idx_events_lamport
|
| 283 |
+
ON events(community_id, lamport, event_id);
|
| 284 |
+
|
| 285 |
+
CREATE INDEX IF NOT EXISTS idx_events_type
|
| 286 |
+
ON events(community_id, event_type, lamport);
|
| 287 |
+
|
| 288 |
+
CREATE TABLE IF NOT EXISTS clock (
|
| 289 |
+
community_id TEXT PRIMARY KEY,
|
| 290 |
+
lamport INTEGER NOT NULL
|
| 291 |
+
);
|
| 292 |
+
```
|
| 293 |
+
|
| 294 |
+
The clock row is updated inside the same transaction as the event insert for atomicity.
|
| 295 |
+
|
| 296 |
+
---
|
| 297 |
+
|
| 298 |
+
## 5. Behaviour
|
| 299 |
+
|
| 300 |
+
### 5.1 Insert flow (local event)
|
| 301 |
+
|
| 302 |
+
```
|
| 303 |
+
begin tx
|
| 304 |
+
lamport = clock + 1
|
| 305 |
+
event_id = ulid()
|
| 306 |
+
build envelope { ... }
|
| 307 |
+
sign with author_kp
|
| 308 |
+
insert into events
|
| 309 |
+
update clock
|
| 310 |
+
commit
|
| 311 |
+
fan out to in-process subscribers
|
| 312 |
+
fan out to remote pubsub subscribers (best-effort)
|
| 313 |
+
emit log line + metrics
|
| 314 |
+
```
|
| 315 |
+
|
| 316 |
+
### 5.2 Insert flow (received event)
|
| 317 |
+
|
| 318 |
+
```
|
| 319 |
+
parse + dataclass validate
|
| 320 |
+
verify signature (M01)
|
| 321 |
+
check author is a current member (read community manifest)
|
| 322 |
+
check event_type ∈ EventType
|
| 323 |
+
if duplicate event_id: return False (no-op)
|
| 324 |
+
begin tx
|
| 325 |
+
clock = max(clock, event.lamport) + 1
|
| 326 |
+
insert
|
| 327 |
+
do NOT bump clock again for this transaction
|
| 328 |
+
commit
|
| 329 |
+
trigger replay engine
|
| 330 |
+
emit log + metrics
|
| 331 |
+
return True
|
| 332 |
+
```
|
| 333 |
+
|
| 334 |
+
### 5.3 Replay correctness
|
| 335 |
+
|
| 336 |
+
- All views must be commutative under (lamport, event_id) ordering
|
| 337 |
+
- The view contract is: starting from `reset()`, applying the full ordered event stream produces the same `snapshot_state()` regardless of insertion order at the log layer
|
| 338 |
+
- Tests assert this with property-based testing (hypothesis)
|
| 339 |
+
|
| 340 |
+
### 5.4 Snapshots
|
| 341 |
+
|
| 342 |
+
- Auto-built once per day at 03:00 local (configurable; off-peak)
|
| 343 |
+
- Lamport at which snapshot covers: `head - SNAPSHOT_LAG_LAMPORT` (default 1000)
|
| 344 |
+
- Older events not deleted unless `EVENT_LOG_RETENTION_DAYS` is exceeded (default 30 days, post-snapshot)
|
| 345 |
+
|
| 346 |
+
### 5.5 Sync rules
|
| 347 |
+
|
| 348 |
+
- Sync runs every 5 minutes (or on demand)
|
| 349 |
+
- During emergency mode, sync runs every 30 seconds with other local nodes only
|
| 350 |
+
- Sync is symmetric and idempotent — running twice causes no double-applies
|
| 351 |
+
|
| 352 |
+
---
|
| 353 |
+
|
| 354 |
+
## 6. Gossip sync protocol (wire)
|
| 355 |
+
|
| 356 |
+
### `GET /sync/v1/heads`
|
| 357 |
+
|
| 358 |
+
Response:
|
| 359 |
+
|
| 360 |
+
```json
|
| 361 |
+
{
|
| 362 |
+
"community_id": "ed25519:...",
|
| 363 |
+
"head": 4218,
|
| 364 |
+
"heads_by_type": {
|
| 365 |
+
"market.post.created": 4218,
|
| 366 |
+
"chat.message.sent": 4192,
|
| 367 |
+
"...": 0
|
| 368 |
+
}
|
| 369 |
+
}
|
| 370 |
+
```
|
| 371 |
+
|
| 372 |
+
### `POST /sync/v1/events`
|
| 373 |
+
|
| 374 |
+
Body:
|
| 375 |
+
|
| 376 |
+
```json
|
| 377 |
+
{
|
| 378 |
+
"community_id": "ed25519:...",
|
| 379 |
+
"events": [ /* full Event objects */ ]
|
| 380 |
+
}
|
| 381 |
+
```
|
| 382 |
+
|
| 383 |
+
Response:
|
| 384 |
+
|
| 385 |
+
```json
|
| 386 |
+
{
|
| 387 |
+
"accepted": 17,
|
| 388 |
+
"rejected": 1,
|
| 389 |
+
"rejected_reasons": [{"event_id": "...", "reason": "invalid_signature"}],
|
| 390 |
+
"new_head": 4235
|
| 391 |
+
}
|
| 392 |
+
```
|
| 393 |
+
|
| 394 |
+
---
|
| 395 |
+
|
| 396 |
+
## 7. Errors
|
| 397 |
+
|
| 398 |
+
`EventLogError` codes:
|
| 399 |
+
|
| 400 |
+
- `invalid_signature` — signature did not validate
|
| 401 |
+
- `out_of_order` — lamport ≤ current AND event_id not novel (very rare; manifests as duplicate)
|
| 402 |
+
- `unknown_author` — author NodeID not in current member list
|
| 403 |
+
- `revoked_author` — author in revoked list at or before the event's lamport
|
| 404 |
+
- `schema_unknown` — `event_type` not in the closed set
|
| 405 |
+
- `db_corrupt` — SQLite integrity check failed
|
| 406 |
+
|
| 407 |
+
---
|
| 408 |
+
|
| 409 |
+
## 8. Configuration
|
| 410 |
+
|
| 411 |
+
From [X04](X04-config.md):
|
| 412 |
+
|
| 413 |
+
```python
|
| 414 |
+
config.community.state_dir # <DATA>/communities/<id>
|
| 415 |
+
```
|
| 416 |
+
|
| 417 |
+
Plus constants from [GLOSSARY.md](../GLOSSARY.md):
|
| 418 |
+
`EVENT_LOG_RETENTION_DAYS`, `SNAPSHOT_LAG_LAMPORT`.
|
| 419 |
+
|
| 420 |
+
---
|
| 421 |
+
|
| 422 |
+
## 9. Tests
|
| 423 |
+
|
| 424 |
+
### Unit
|
| 425 |
+
- `test_lamport_send_increments`
|
| 426 |
+
- `test_lamport_observe_caps_above`
|
| 427 |
+
- `test_append_local_persists_and_signs`
|
| 428 |
+
- `test_append_received_rejects_bad_signature`
|
| 429 |
+
- `test_replay_idempotent` — replay twice → same state
|
| 430 |
+
- `test_replay_order_independence` — Hypothesis-driven, shuffle event arrival order → same end state
|
| 431 |
+
- `test_snapshot_roundtrip` — build, write, read, restore → equal state
|
| 432 |
+
- `test_duplicate_event_id_is_noop`
|
| 433 |
+
|
| 434 |
+
### Integration
|
| 435 |
+
- `test_two_node_sync_converges` — drift one node, sync, both equal
|
| 436 |
+
- `test_three_way_partition_then_heal` — three nodes, partition into 1+2, partial sync, heal, all equal
|
| 437 |
+
- `test_snapshot_assisted_join` — new node bootstraps from snapshot + recent events faster than full replay
|
| 438 |
+
|
| 439 |
+
---
|
| 440 |
+
|
| 441 |
+
## 10. Cross-references
|
| 442 |
+
|
| 443 |
+
| What | Where |
|
| 444 |
+
|------|-------|
|
| 445 |
+
| Event envelope | [CONTRACT §7.1](../CAPABILITY_CONTRACT.md) |
|
| 446 |
+
| Event types catalogue | [CONTRACT §7.2](../CAPABILITY_CONTRACT.md) |
|
| 447 |
+
| Lamport rules | [CONTRACT §7.3](../CAPABILITY_CONTRACT.md) |
|
| 448 |
+
| Sync protocol on wire | [CONTRACT §7.6](../CAPABILITY_CONTRACT.md) |
|
| 449 |
+
| Marketplace view | [M06 §4](../modules/M06-marketplace.md) |
|
| 450 |
+
| Chat view | [M10 §4](../modules/M10-chat.md) |
|
| 451 |
+
| Community manifest derivation | [M01 §3.2](../modules/M01-identity.md) |
|
| 452 |
+
| Signing | [M01 §3.1](../modules/M01-identity.md) |
|
docs/X03-observability.md
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# X03 — Observability
|
| 2 |
+
|
| 3 |
+
**Spec version:** v1.0
|
| 4 |
+
**Depends on:** X04 (config), stdlib, prometheus_client (optional)
|
| 5 |
+
**Depended on by:** every module that does I/O
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 1. Responsibility
|
| 10 |
+
|
| 11 |
+
Provides logging, metrics, tracing, and self-diagnostics. No module imports `logging` directly; they import `get_logger(__name__)` from this module.
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## 2. File layout
|
| 16 |
+
|
| 17 |
+
```
|
| 18 |
+
hearthnet/observability/
|
| 19 |
+
├── __init__.py # exports: get_logger, metrics, trace, doctor
|
| 20 |
+
├── logging.py # structured JSON logging
|
| 21 |
+
├── metrics.py # Prometheus-compatible counters/histograms
|
| 22 |
+
├── tracing.py # per-request trace IDs + ring buffer
|
| 23 |
+
└── doctor.py # self-diagnostics
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
## 3. Logging
|
| 29 |
+
|
| 30 |
+
### 3.1 Public API
|
| 31 |
+
|
| 32 |
+
```python
|
| 33 |
+
# hearthnet.observability.logging
|
| 34 |
+
|
| 35 |
+
def configure(config: ObservabilityConfig) -> None:
|
| 36 |
+
"""Install handlers, formatters, rotation. Idempotent. Call once at startup."""
|
| 37 |
+
|
| 38 |
+
def get_logger(name: str) -> Logger:
|
| 39 |
+
"""Return a stdlib logger configured to emit JSON lines.
|
| 40 |
+
Convention: name = module's __name__ (e.g. 'hearthnet.bus.router')."""
|
| 41 |
+
|
| 42 |
+
class JsonFormatter(logging.Formatter):
|
| 43 |
+
"""Renders LogRecords as one-line JSON: ts, level, logger, msg, **extras."""
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
### 3.2 Conventions
|
| 47 |
+
|
| 48 |
+
- Use `extra=` to attach structured fields: `log.info("routed", extra={"capability": "llm.chat", "to": node_id, "ms": 12})`
|
| 49 |
+
- Never `f"log message {variable}"` for production diagnostics; use structured fields instead
|
| 50 |
+
- Log levels:
|
| 51 |
+
- `debug` — internal state, only useful with `--debug`
|
| 52 |
+
- `info` — meaningful protocol events (manifest received, capability registered, peer joined)
|
| 53 |
+
- `warning` — recoverable problem (signature failed, peer unreachable, quarantine)
|
| 54 |
+
- `error` — unexpected failure (exception caught, service crash)
|
| 55 |
+
- Exceptions: always `log.exception("what happened", extra={...})` — captures traceback automatically
|
| 56 |
+
- Rate-limit noisy warnings: `RateLimitedLogger` wrapper, log at most once per second per (logger, message_key)
|
| 57 |
+
|
| 58 |
+
### 3.3 On-disk format
|
| 59 |
+
|
| 60 |
+
```json
|
| 61 |
+
{"ts":"2026-05-26T08:14:22.281Z","level":"info","logger":"hearthnet.bus.router","msg":"routed","trace_id":"01HXR...","capability":"llm.chat","to":"7H4G-...","ms":12}
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
One line per event. Files rotate daily at midnight UTC. Retention: `LOG_RETENTION_DAYS = 14` from constants.
|
| 65 |
+
|
| 66 |
+
---
|
| 67 |
+
|
| 68 |
+
## 4. Metrics
|
| 69 |
+
|
| 70 |
+
### 4.1 Public API
|
| 71 |
+
|
| 72 |
+
```python
|
| 73 |
+
# hearthnet.observability.metrics
|
| 74 |
+
|
| 75 |
+
def configure(config: ObservabilityConfig) -> None:
|
| 76 |
+
"""Set up registries, start the metrics endpoint if enabled."""
|
| 77 |
+
|
| 78 |
+
# Counter / histogram / gauge factory functions:
|
| 79 |
+
def counter(name: str, doc: str, labels: list[str] = []) -> Counter
|
| 80 |
+
def histogram(name: str, doc: str, labels: list[str] = [], buckets: list[float] | None = None) -> Histogram
|
| 81 |
+
def gauge(name: str, doc: str, labels: list[str] = []) -> Gauge
|
| 82 |
+
|
| 83 |
+
# Convenience for "everything else returns None when metrics disabled":
|
| 84 |
+
def disabled() -> bool
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
### 4.2 Standard metric set
|
| 88 |
+
|
| 89 |
+
```
|
| 90 |
+
hearthnet_requests_total{capability, result} counter
|
| 91 |
+
hearthnet_request_duration_ms{capability, quantile} histogram
|
| 92 |
+
hearthnet_active_streams{capability} gauge
|
| 93 |
+
hearthnet_nodes_online{community} gauge
|
| 94 |
+
hearthnet_event_log_size{community} gauge
|
| 95 |
+
hearthnet_event_log_lamport_head{community} gauge
|
| 96 |
+
hearthnet_emergency_mode{state} gauge // 0 or 1
|
| 97 |
+
hearthnet_blob_storage_bytes gauge
|
| 98 |
+
hearthnet_llm_tokens_generated_total{model, backend} counter
|
| 99 |
+
hearthnet_llm_concurrent{model} gauge
|
| 100 |
+
hearthnet_capability_health_success_rate{capability, node} gauge
|
| 101 |
+
hearthnet_rate_limited_total{capability, reason} counter
|
| 102 |
+
hearthnet_signature_failures_total{reason} counter
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
### 4.3 Scrape endpoint
|
| 106 |
+
|
| 107 |
+
`GET /metrics` on the transport server (port 7080). Plain text, Prometheus format. No auth — same trust domain as the rest of the bus.
|
| 108 |
+
|
| 109 |
+
---
|
| 110 |
+
|
| 111 |
+
## 5. Tracing
|
| 112 |
+
|
| 113 |
+
### 5.1 Public API
|
| 114 |
+
|
| 115 |
+
```python
|
| 116 |
+
# hearthnet.observability.tracing
|
| 117 |
+
|
| 118 |
+
class Trace:
|
| 119 |
+
trace_id: str
|
| 120 |
+
capability: str
|
| 121 |
+
started_at: float # monotonic seconds
|
| 122 |
+
spans: list[Span]
|
| 123 |
+
|
| 124 |
+
@contextmanager
|
| 125 |
+
def span(name: str, **extras) -> Iterator[Span]:
|
| 126 |
+
"""Open a sub-span on the current trace. Auto-closes."""
|
| 127 |
+
|
| 128 |
+
def new_trace(capability: str) -> Trace:
|
| 129 |
+
"""Start a new trace (typically at the top of a capability handler)."""
|
| 130 |
+
|
| 131 |
+
def current_trace() -> Trace | None:
|
| 132 |
+
"""Get the trace attached to the current asyncio task."""
|
| 133 |
+
|
| 134 |
+
def attach(trace: Trace) -> None:
|
| 135 |
+
"""Attach a trace to the current task. Used by transport when it receives a request with an X-HearthNet-Request-Id."""
|
| 136 |
+
|
| 137 |
+
def detach() -> None:
|
| 138 |
+
"""End the trace; record to the ring buffer; emit done log."""
|
| 139 |
+
|
| 140 |
+
def get_recent(n: int = 100) -> list[Trace]:
|
| 141 |
+
"""Return last N completed traces from the ring buffer (used by /trace endpoint)."""
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
### 5.2 Storage
|
| 145 |
+
|
| 146 |
+
Ring buffer in memory, `TRACE_RING_BUFFER = 10000` from constants. Optionally exported to OpenTelemetry in Phase 2.
|
| 147 |
+
|
| 148 |
+
### 5.3 Trace IDs are ULIDs
|
| 149 |
+
|
| 150 |
+
ULIDs are used because they sort by time and need no separate timestamp field.
|
| 151 |
+
|
| 152 |
+
---
|
| 153 |
+
|
| 154 |
+
## 6. Doctor
|
| 155 |
+
|
| 156 |
+
### 6.1 Public API
|
| 157 |
+
|
| 158 |
+
```python
|
| 159 |
+
# hearthnet.observability.doctor
|
| 160 |
+
|
| 161 |
+
@dataclass
|
| 162 |
+
class CheckResult:
|
| 163 |
+
name: str
|
| 164 |
+
ok: bool
|
| 165 |
+
detail: str
|
| 166 |
+
fix: str | None
|
| 167 |
+
|
| 168 |
+
def run_all(config: Config, bus: CapabilityBus) -> list[CheckResult]:
|
| 169 |
+
"""Run every check; return list of results."""
|
| 170 |
+
|
| 171 |
+
def run_one(name: str, config: Config, bus: CapabilityBus) -> CheckResult:
|
| 172 |
+
"""Run a single named check."""
|
| 173 |
+
|
| 174 |
+
# Each check is a registered function:
|
| 175 |
+
def register(name: str, check: Callable[[Config, CapabilityBus], CheckResult]) -> None
|
| 176 |
+
```
|
| 177 |
+
|
| 178 |
+
### 6.2 Standard checks
|
| 179 |
+
|
| 180 |
+
| Name | Verifies |
|
| 181 |
+
|------|----------|
|
| 182 |
+
| `keys_present` | Device key file exists, has 0600 permissions |
|
| 183 |
+
| `keys_loadable` | Keys parse as Ed25519 |
|
| 184 |
+
| `community_present` | Community manifest exists |
|
| 185 |
+
| `event_log_writable` | SQLite open and writable |
|
| 186 |
+
| `mdns_socket` | mDNS socket can bind |
|
| 187 |
+
| `udp_multicast` | UDP discovery socket can bind |
|
| 188 |
+
| `transport_port` | Bus port is free or owned by us |
|
| 189 |
+
| `at_least_one_capability` | Bus has registered ≥ 1 capability |
|
| 190 |
+
| `disk_space` | Free space ≥ 1 GB |
|
| 191 |
+
| `clock_sanity` | System clock within ±60s of HTTP-reachable anchor (only when internet up) |
|
| 192 |
+
| `llm_backend_reachable` | At least one LLM backend responds |
|
| 193 |
+
| `recent_error_rate` | Last 100 traces have < 20% error rate |
|
| 194 |
+
|
| 195 |
+
### 6.3 CLI integration
|
| 196 |
+
|
| 197 |
+
`hearthnet doctor` runs `run_all`, prints a coloured report, exits non-zero on any failure. See [M12](../modules/M12-cli.md).
|
| 198 |
+
|
| 199 |
+
---
|
| 200 |
+
|
| 201 |
+
## 7. Tests
|
| 202 |
+
|
| 203 |
+
- `test_logger_writes_json_lines` — assert each line parses as JSON with expected fields
|
| 204 |
+
- `test_metrics_endpoint_format` — Prometheus text format conforms
|
| 205 |
+
- `test_trace_context_propagation` — `attach`/`detach` round-trips across `asyncio.gather`
|
| 206 |
+
- `test_doctor_all_pass_on_default_config` — `run_all` returns all-OK on fresh init
|
| 207 |
+
- `test_doctor_keys_missing` — failure case for `keys_present`
|
| 208 |
+
|
| 209 |
+
---
|
| 210 |
+
|
| 211 |
+
## 8. References
|
| 212 |
+
|
| 213 |
+
- Config: [X04 §3](X04-config.md)
|
| 214 |
+
- Trace IDs propagate via [CONTRACT §5.1](../CAPABILITY_CONTRACT.md) `X-HearthNet-Request-Id`
|
| 215 |
+
- Bus emits trace events: [M03 §5.6](../modules/M03-bus.md)
|
docs/X04-config.md
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# X04 — Configuration
|
| 2 |
+
|
| 3 |
+
**Spec version:** v1.0
|
| 4 |
+
**Depends on:** stdlib only
|
| 5 |
+
**Depended on by:** every module
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 1. Responsibility
|
| 10 |
+
|
| 11 |
+
Single source of runtime configuration. Loads from disk, validates, and exposes a typed `Config` object. No module reads environment variables, files, or CLI flags directly — they all read from a `Config` instance handed to them.
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## 2. File layout
|
| 16 |
+
|
| 17 |
+
```
|
| 18 |
+
hearthnet/
|
| 19 |
+
├── config.py # implementation
|
| 20 |
+
└── constants.py # immutable numeric constants (from GLOSSARY.md §Defaults)
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
`config.toml` lives at `<CONFIG>/config.toml` (see [GLOSSARY.md](../GLOSSARY.md) for path resolution).
|
| 24 |
+
|
| 25 |
+
---
|
| 26 |
+
|
| 27 |
+
## 3. The Config object
|
| 28 |
+
|
| 29 |
+
```python
|
| 30 |
+
# hearthnet/config.py
|
| 31 |
+
from dataclasses import dataclass, field
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
from typing import Optional
|
| 34 |
+
|
| 35 |
+
@dataclass(frozen=True)
|
| 36 |
+
class IdentityConfig:
|
| 37 |
+
"""Where keys live, and whether to auto-generate if missing."""
|
| 38 |
+
keys_dir: Path # <DATA>/keys
|
| 39 |
+
auto_generate: bool = True
|
| 40 |
+
|
| 41 |
+
@dataclass(frozen=True)
|
| 42 |
+
class CommunityConfig:
|
| 43 |
+
"""Which community this node belongs to, and where its state lives."""
|
| 44 |
+
community_id: Optional[str] = None # None → must run `hearthnet init`
|
| 45 |
+
state_dir: Path = Path() # <DATA>/communities/<id>
|
| 46 |
+
|
| 47 |
+
@dataclass(frozen=True)
|
| 48 |
+
class TransportConfig:
|
| 49 |
+
host: str = "0.0.0.0"
|
| 50 |
+
port: int = 7080
|
| 51 |
+
tls_cert: Optional[Path] = None # None → self-signed
|
| 52 |
+
tls_key: Optional[Path] = None
|
| 53 |
+
|
| 54 |
+
@dataclass(frozen=True)
|
| 55 |
+
class DiscoveryConfig:
|
| 56 |
+
mdns_enabled: bool = True
|
| 57 |
+
udp_enabled: bool = True
|
| 58 |
+
udp_multicast_group: str = "239.255.42.42"
|
| 59 |
+
udp_port: int = 42424
|
| 60 |
+
relay_urls: list[str] = field(default_factory=list) # Phase 2
|
| 61 |
+
|
| 62 |
+
@dataclass(frozen=True)
|
| 63 |
+
class BusConfig:
|
| 64 |
+
prefer_local: bool = True
|
| 65 |
+
local_load_threshold: float = 0.80
|
| 66 |
+
|
| 67 |
+
@dataclass(frozen=True)
|
| 68 |
+
class LlmBackendConfig:
|
| 69 |
+
name: str # "llama_cpp" | "ollama" | "lmstudio" | "hf_api" | "anthropic_api"
|
| 70 |
+
url: Optional[str] = None
|
| 71 |
+
model: Optional[str] = None
|
| 72 |
+
api_key_env: Optional[str] = None # env var name; the actual key is never stored in config
|
| 73 |
+
|
| 74 |
+
@dataclass(frozen=True)
|
| 75 |
+
class LlmConfig:
|
| 76 |
+
backends: list[LlmBackendConfig] = field(default_factory=list)
|
| 77 |
+
|
| 78 |
+
@dataclass(frozen=True)
|
| 79 |
+
class EmbeddingConfig:
|
| 80 |
+
model: str = "BAAI/bge-small-en-v1.5"
|
| 81 |
+
device: str = "auto" # "cpu" | "cuda" | "auto"
|
| 82 |
+
|
| 83 |
+
@dataclass(frozen=True)
|
| 84 |
+
class RagConfig:
|
| 85 |
+
enabled: bool = True
|
| 86 |
+
corpora_dir: Path = Path() # <CACHE>/embeddings
|
| 87 |
+
|
| 88 |
+
@dataclass(frozen=True)
|
| 89 |
+
class FileConfig:
|
| 90 |
+
blobs_dir: Path = Path() # <DATA>/blobs
|
| 91 |
+
gc_threshold: float = 0.80
|
| 92 |
+
|
| 93 |
+
@dataclass(frozen=True)
|
| 94 |
+
class MarketConfig:
|
| 95 |
+
enabled: bool = True
|
| 96 |
+
default_ttl_seconds: int = 86400 * 7 # 7 days
|
| 97 |
+
max_ttl_seconds: int = 86400 * 30
|
| 98 |
+
|
| 99 |
+
@dataclass(frozen=True)
|
| 100 |
+
class ChatConfig:
|
| 101 |
+
enabled: bool = True
|
| 102 |
+
store_and_forward: bool = True
|
| 103 |
+
|
| 104 |
+
@dataclass(frozen=True)
|
| 105 |
+
class EmergencyConfig:
|
| 106 |
+
probe_targets: list[str] = field(default_factory=lambda: [
|
| 107 |
+
"1.1.1.1", "8.8.8.8", "cloudflare.com", "quad9.net"
|
| 108 |
+
])
|
| 109 |
+
|
| 110 |
+
@dataclass(frozen=True)
|
| 111 |
+
class UiConfig:
|
| 112 |
+
host: str = "127.0.0.1"
|
| 113 |
+
port: int = 7860
|
| 114 |
+
launch_browser: bool = True
|
| 115 |
+
|
| 116 |
+
@dataclass(frozen=True)
|
| 117 |
+
class ObservabilityConfig:
|
| 118 |
+
log_level: str = "info"
|
| 119 |
+
log_dir: Path = Path() # <LOG>
|
| 120 |
+
metrics_enabled: bool = True
|
| 121 |
+
otlp_endpoint: Optional[str] = None # Phase 2
|
| 122 |
+
|
| 123 |
+
@dataclass(frozen=True)
|
| 124 |
+
class Config:
|
| 125 |
+
identity: IdentityConfig
|
| 126 |
+
community: CommunityConfig
|
| 127 |
+
transport: TransportConfig
|
| 128 |
+
discovery: DiscoveryConfig
|
| 129 |
+
bus: BusConfig
|
| 130 |
+
llm: LlmConfig
|
| 131 |
+
embedding: EmbeddingConfig
|
| 132 |
+
rag: RagConfig
|
| 133 |
+
file: FileConfig
|
| 134 |
+
market: MarketConfig
|
| 135 |
+
chat: ChatConfig
|
| 136 |
+
emergency: EmergencyConfig
|
| 137 |
+
ui: UiConfig
|
| 138 |
+
observability: ObservabilityConfig
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
---
|
| 142 |
+
|
| 143 |
+
## 4. Public API
|
| 144 |
+
|
| 145 |
+
### `load(path: Path | None = None) -> Config`
|
| 146 |
+
|
| 147 |
+
Loads from `path` if given, otherwise from the platform-standard location. Applies defaults for omitted sections. Validates and returns a frozen `Config`.
|
| 148 |
+
|
| 149 |
+
Raises:
|
| 150 |
+
- `ConfigError("invalid_toml")` — TOML parse failure
|
| 151 |
+
- `ConfigError("invalid_field", field=...)` — type or value validation
|
| 152 |
+
- `ConfigError("path_resolution")` — XDG resolution failed (e.g. read-only filesystem)
|
| 153 |
+
|
| 154 |
+
### `default_config() -> Config`
|
| 155 |
+
|
| 156 |
+
Returns a Config populated entirely from defaults. Used by tests and `hearthnet init`.
|
| 157 |
+
|
| 158 |
+
### `save(config: Config, path: Path | None = None) -> None`
|
| 159 |
+
|
| 160 |
+
Serialises a Config to TOML and writes atomically (write to tempfile, rename). Used by `hearthnet init`.
|
| 161 |
+
|
| 162 |
+
### `resolve_paths(config: Config) -> Config`
|
| 163 |
+
|
| 164 |
+
Resolves empty `Path()` fields to their canonical XDG locations. Called by `load()` automatically. Idempotent.
|
| 165 |
+
|
| 166 |
+
### `validate(config: Config) -> None`
|
| 167 |
+
|
| 168 |
+
Cross-field validation (e.g. transport port not equal to udp port). Raises `ConfigError` on failure. Called by `load()`.
|
| 169 |
+
|
| 170 |
+
### `ConfigError(Exception)`
|
| 171 |
+
|
| 172 |
+
```python
|
| 173 |
+
class ConfigError(Exception):
|
| 174 |
+
def __init__(self, code: str, **details):
|
| 175 |
+
self.code = code
|
| 176 |
+
self.details = details
|
| 177 |
+
super().__init__(f"{code}: {details}")
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
---
|
| 181 |
+
|
| 182 |
+
## 5. Default config.toml
|
| 183 |
+
|
| 184 |
+
```toml
|
| 185 |
+
[identity]
|
| 186 |
+
auto_generate = true
|
| 187 |
+
|
| 188 |
+
[community]
|
| 189 |
+
# community_id is set by `hearthnet init`
|
| 190 |
+
|
| 191 |
+
[transport]
|
| 192 |
+
host = "0.0.0.0"
|
| 193 |
+
port = 7080
|
| 194 |
+
|
| 195 |
+
[discovery]
|
| 196 |
+
mdns_enabled = true
|
| 197 |
+
udp_enabled = true
|
| 198 |
+
|
| 199 |
+
[bus]
|
| 200 |
+
prefer_local = true
|
| 201 |
+
local_load_threshold = 0.8
|
| 202 |
+
|
| 203 |
+
[[llm.backends]]
|
| 204 |
+
name = "lmstudio"
|
| 205 |
+
url = "http://192.168.188.25:1234"
|
| 206 |
+
model = "qwen2.5-7b-instruct"
|
| 207 |
+
|
| 208 |
+
[embedding]
|
| 209 |
+
model = "BAAI/bge-small-en-v1.5"
|
| 210 |
+
device = "auto"
|
| 211 |
+
|
| 212 |
+
[rag]
|
| 213 |
+
enabled = true
|
| 214 |
+
|
| 215 |
+
[file]
|
| 216 |
+
gc_threshold = 0.8
|
| 217 |
+
|
| 218 |
+
[market]
|
| 219 |
+
enabled = true
|
| 220 |
+
default_ttl_seconds = 604800
|
| 221 |
+
max_ttl_seconds = 2592000
|
| 222 |
+
|
| 223 |
+
[chat]
|
| 224 |
+
enabled = true
|
| 225 |
+
store_and_forward = true
|
| 226 |
+
|
| 227 |
+
[ui]
|
| 228 |
+
host = "127.0.0.1"
|
| 229 |
+
port = 7860
|
| 230 |
+
launch_browser = true
|
| 231 |
+
|
| 232 |
+
[observability]
|
| 233 |
+
log_level = "info"
|
| 234 |
+
metrics_enabled = true
|
| 235 |
+
```
|
| 236 |
+
|
| 237 |
+
---
|
| 238 |
+
|
| 239 |
+
## 6. Cross-cutting constants
|
| 240 |
+
|
| 241 |
+
`hearthnet/constants.py` holds the values from [GLOSSARY.md §Defaults](../GLOSSARY.md). These are NOT configurable. Examples:
|
| 242 |
+
|
| 243 |
+
```python
|
| 244 |
+
MANIFEST_TTL_SECONDS = 30
|
| 245 |
+
MANIFEST_REPUBLISH_INTERVAL_SECONDS = 20
|
| 246 |
+
EMERGENCY_PROBE_INTERVAL_ONLINE = 10
|
| 247 |
+
EMERGENCY_PROBE_INTERVAL_OFFLINE = 2
|
| 248 |
+
STREAM_WINDOW_FRAMES = 16
|
| 249 |
+
CHUNK_SIZE_BYTES = 262144
|
| 250 |
+
HEALTH_WINDOW_CALLS = 100
|
| 251 |
+
HEALTH_QUARANTINE_THRESHOLD = 0.5
|
| 252 |
+
HEALTH_QUARANTINE_SECONDS = 60
|
| 253 |
+
# ... see GLOSSARY.md for the complete list
|
| 254 |
+
```
|
| 255 |
+
|
| 256 |
+
Rationale for non-configurability: these affect interop. A node tweaking `MANIFEST_TTL_SECONDS` will desync from the network.
|
| 257 |
+
|
| 258 |
+
---
|
| 259 |
+
|
| 260 |
+
## 7. Tests
|
| 261 |
+
|
| 262 |
+
- `test_default_config_round_trips` — `save(default_config()); load()` returns equal config
|
| 263 |
+
- `test_invalid_toml_raises` — malformed TOML → `ConfigError("invalid_toml")`
|
| 264 |
+
- `test_missing_required_field_raises` — community section without `community_id` is OK (post-init); but other validations apply
|
| 265 |
+
- `test_path_resolution_xdg` — empty paths resolve to user_data_dir, etc.
|
| 266 |
+
- `test_env_var_substitution` — `${ENV_VAR}` in TOML strings is expanded by `load()`
|
| 267 |
+
|
| 268 |
+
---
|
| 269 |
+
|
| 270 |
+
## 8. References
|
| 271 |
+
|
| 272 |
+
- Constants list: [GLOSSARY.md](../GLOSSARY.md) §Defaults
|
| 273 |
+
- Used by all modules; this is the universal entry point
|