Chris4K commited on
Commit
dcfd8f4
·
verified ·
1 Parent(s): afd5110

prd splitted + contracts

Browse files
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