| # HearthNet Capability Contract |
|
|
| **Spec version:** v1.0 |
| **Last touched:** 2026-06-07 |
| **Scope:** wire-level protocol, capability schemas, event schemas, signing rules. |
|
|
| This document is the source of truth. Any conflict with a module spec is resolved in favour of this document. |
|
|
| --- |
|
|
| ## 1. Conventions |
|
|
| ### 1.1 Encodings |
|
|
| - **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. |
| - **Binary content**: Base64-URL without padding, prefixed by encoding tag where ambiguous (`ed25519:`, `blake3:`). |
| - **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). |
| - **Durations**: integer seconds, suffix `_seconds` in field names. |
| - **Sizes**: integer bytes, suffix `_bytes`. UI may convert to KB/MB; wire never does. |
|
|
| ### 1.2 Canonical JSON |
|
|
| For any payload that is signed or hashed, the canonical form is: |
|
|
| - Keys sorted lexicographically at every level |
| - No whitespace between tokens |
| - Numbers without trailing zeros: `1.0` β `1`, `1.10` β `1.1` |
| - Strings UTF-8, non-ASCII characters not escaped |
| - `null` allowed only where the schema declares it |
|
|
| A reference implementation is `hearthnet.identity.keys.canonical_json(obj) -> bytes`. Use it always. |
|
|
| ### 1.3 Signing primitive |
|
|
| 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. |
|
|
| ### 1.4 Hashing primitive |
|
|
| 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. |
|
|
| ### 1.5 Identifier forms |
|
|
| See [GLOSSARY.md](GLOSSARY.md). Identifiers in protocol payloads always use the full form. Display forms are UI-only. |
|
|
| --- |
|
|
| ## 2. Versioning |
|
|
| ### 2.1 Capability version |
|
|
| A capability declares `version: "X.Y"` where X is major, Y is minor. |
|
|
| - **Compatibility**: a request asking for `name@>=A.B` is satisfied by an offered `name@X.Y` iff `X == A` and `Y >= B`. |
| - **Major bumps**: breaking. Old callers receive `schema_mismatch`. |
| - **Minor bumps**: additive only. Old callers continue to work. New fields are optional with documented defaults. |
| - 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. |
|
|
| ### 2.2 Contract version |
|
|
| This document is versioned independently of capabilities. Node manifests carry `contract_version: "1.0"` so peers can refuse to talk to incompatible contract revisions. |
|
|
| ### 2.3 Event schema version |
|
|
| Each event carries a `schema_version` field. Old events are kept verbatim; readers translate via a versioned schema registry. Never rewrite history. |
|
|
| --- |
|
|
| ## 3. Capability namespace |
|
|
| ### 3.1 Prefix allocation |
|
|
| | Prefix | Owner | Stability of prefix | Defined in | |
| |--------|-------|---------------------|------------| |
| | `llm.*` | LLM service | stable | M04 | |
| | `embed.*` | Embedding service | stable | M11 | |
| | `rag.*` | RAG service | stable | M05 | |
| | `file.*` | File service | stable | M07 | |
| | `market.*` | Marketplace | stable | M06 | |
| | `chat.*` | Chat | stable | M10 | |
| | `community.*` | Trust ops | stable | M01 + X02 | |
| | `federation.*` | Cross-community | beta | Phase 2 | |
| | `ocr.*` | OCR (Phase 2) | reserved | β | |
| | `tts.*` `stt.*` | Speech (Phase 2) | reserved | β | |
| | `trans.*` | Translation (Phase 2) | reserved | β | |
| | `img.*` | Images (Phase 2) | reserved | β | |
| | `experimental.*` | Anything not promoted | unstable | any | |
|
|
| Reserved prefixes may not be used. Capabilities outside the reserved set must start with `experimental.`. |
|
|
| ### 3.2 Complete capability list (this release) |
|
|
| | Name | Stability | Stream? | Trust required | Section | |
| |------|-----------|---------|----------------|---------| |
| | `llm.chat@1.0` | stable | yes | member | Β§4.1 | |
| | `llm.complete@1.0` | stable | yes | member | Β§4.2 | |
| | `embed.text@1.0` | stable | no | member | Β§4.3 | |
| | `rag.query@1.0` | stable | no | member | Β§4.4 | |
| | `rag.ingest@1.0` | stable | no | trusted | Β§4.5 | |
| | `rag.list_corpora@1.0` | stable | no | member | Β§4.6 | |
| | `file.read@1.0` | stable | yes (chunks) | member | Β§4.7 | |
| | `file.list@1.0` | stable | no | member | Β§4.8 | |
| | `file.advertise@1.0` | stable | no | member | Β§4.9 | |
| | `file.put@1.0` | stable | yes (chunks) | trusted | Β§4.10 | |
| | `market.list@1.0` | stable | no | member | Β§4.11 | |
| | `market.post@1.0` | stable | no | member | Β§4.12 | |
| | `market.expire@1.0` | stable | no | member (own only) | Β§4.13 | |
| | `market.search@1.0` | stable | no | member | Β§4.14 | |
| | `chat.send@1.0` | stable | no | member | Β§4.15 | |
| | `chat.history@1.0` | stable | no | member (self only) | Β§4.16 | |
| | `community.invite@1.0` | stable | no | member with invite right | Β§4.17 | |
| | `community.revoke@1.0` | stable | no | 3 of trusted | Β§4.18 | |
|
|
| `federation.*`, `ocr.*`, `tts.*`, `stt.*`, `trans.*`, `img.*` are out of scope for this release; placeholders only. |
|
|
| --- |
|
|
| ## 4. Per-capability specifications |
|
|
| For each capability the spec gives: |
|
|
| - **Purpose** |
| - **Trust required** |
| - **Idempotency** |
| - **Request schema** (JSON Schema-ish; required fields marked) |
| - **Response schema** (or stream frame schema) |
| - **Errors** (codes that this capability may return beyond the universal set) |
| - **Example request and response** |
|
|
| The universal error codes apply to every capability: `bad_request`, `unauthorized`, `revoked`, `capacity_exceeded`, `internal_error`, `timeout`, `partition`, `invalid_signature`, `expired`, `rate_limited`. |
|
|
| ### 4.1 `llm.chat@1.0` |
|
|
| - **Purpose**: Multi-turn chat completion. Server-streams tokens. |
| - **Trust**: member |
| - **Idempotency**: no (token sampling is non-deterministic) |
| - **Stream**: yes (SSE) |
|
|
| #### Request |
|
|
| ```json |
| { |
| "params": { |
| "model": "qwen2.5-7b-instruct", // required, must match an offered model |
| "ctx": 8192 // optional, default = declared max |
| }, |
| "input": { |
| "messages": [ // required, β₯ 1 |
| {"role": "system", "content": "..."}, // optional |
| {"role": "user", "content": "..."}, // required at least once |
| {"role": "assistant", "content": "..."} |
| ], |
| "max_tokens": 512, // optional, default 1024 |
| "temperature": 0.7, // optional, default 0.7 |
| "top_p": 0.95, // optional, default 0.95 |
| "stop": ["</s>"], // optional |
| "seed": 42, // optional; if set, server SHOULD make deterministic |
| "tools": [], // optional, OpenAI-compatible tool defs (Phase 2) |
| "tool_choice": "auto", // optional |
| "stream": true // optional, default true |
| } |
| } |
| ``` |
|
|
| #### Response β non-stream (only if `stream:false`) |
|
|
| ```json |
| { |
| "output": { |
| "message": {"role": "assistant", "content": "..."}, |
| "tool_calls": [] // optional |
| }, |
| "meta": { |
| "model": "qwen2.5-7b-instruct", |
| "tokens_in": 42, |
| "tokens_out": 178, |
| "stop_reason": "end", // "end" | "max_tokens" | "stop_sequence" | "cancelled" |
| "ms": 1834 |
| } |
| } |
| ``` |
|
|
| #### Stream frames |
|
|
| ``` |
| event: token |
| data: {"text":"Sie ", "logprob": -0.21} |
| |
| event: tool_call_delta (only if tools used; Phase 2) |
| data: {"id":"...","name":"search","arguments_delta":"{\"q\":\"..."} |
| |
| event: done |
| data: {"tokens_out": 178, "stop_reason": "end", "ms": 1834} |
| ``` |
|
|
| A `done` frame is always sent. Client closing the connection mid-stream cancels generation within 200ms (server SHOULD abort). |
|
|
| #### Errors |
|
|
| Beyond universal: |
| - `not_implemented` β server registered the capability but backend is missing the model |
| - `bad_request` β malformed messages, empty messages, role sequence violation |
|
|
| ### 4.2 `llm.complete@1.0` |
|
|
| - **Purpose**: Single-shot completion (no chat structure). Used by RAG internally and by classical tooling. |
| - **Trust**: member |
| - **Stream**: yes |
|
|
| #### Request |
|
|
| ```json |
| { |
| "params": {"model": "qwen2.5-7b-instruct"}, |
| "input": { |
| "prompt": "...", // required |
| "max_tokens": 256, |
| "temperature": 0.7, |
| "top_p": 0.95, |
| "stop": ["\n\n"], |
| "seed": null, |
| "stream": true |
| } |
| } |
| ``` |
|
|
| #### Response (non-stream) |
|
|
| ```json |
| { |
| "output": {"text": "..."}, |
| "meta": {"model": "...", "tokens_in": 12, "tokens_out": 80, "stop_reason": "end", "ms": 312} |
| } |
| ``` |
|
|
| #### Stream frames |
|
|
| Same as `llm.chat` but only `token` and `done`. No `tool_call_delta`. |
|
|
| ### 4.3 `embed.text@1.0` |
|
|
| - **Purpose**: Embed one or many strings into vectors. |
| - **Trust**: member |
| - **Idempotency**: yes (assuming deterministic backend) |
|
|
| #### Request |
|
|
| ```json |
| { |
| "params": {"model": "bge-small-en-v1.5"}, |
| "input": { |
| "texts": ["...", "...", "..."], // required, 1..256 |
| "normalize": true // optional, default true |
| } |
| } |
| ``` |
|
|
| #### Response |
|
|
| ```json |
| { |
| "output": { |
| "embeddings": [[0.012, -0.043, ...], [...], [...]], |
| "dim": 384 |
| }, |
| "meta": {"model": "bge-small-en-v1.5", "ms": 38} |
| } |
| ``` |
|
|
| #### Errors |
|
|
| - `bad_request` β > 256 texts, or any text > 8192 chars |
|
|
| ### 4.4 `rag.query@1.0` |
|
|
| - **Purpose**: Retrieve top-K relevant chunks from a named corpus. |
| - **Trust**: member |
| - **Idempotency**: yes |
|
|
| #### Request |
|
|
| ```json |
| { |
| "params": {"corpus": "niederrhein-emergency"}, |
| "input": { |
| "query": "Wie reinige ich Regenwasser?", |
| "k": 5, // optional, default 5, max 20 |
| "filter": { // optional metadata filter |
| "language": "de", |
| "min_year": 2000 |
| }, |
| "include_text": true // optional, default true |
| } |
| } |
| ``` |
|
|
| #### Response |
|
|
| ```json |
| { |
| "output": { |
| "chunks": [ |
| { |
| "rank": 1, |
| "score": 0.84, |
| "text": "Regenwasser kann durch Filtration ...", |
| "metadata": { |
| "doc_cid": "blake3:...", |
| "doc_title": "Notfall-Trinkwasser", |
| "page": 12, |
| "chunk_id": "ch_001" |
| } |
| } |
| ] |
| }, |
| "meta": {"corpus": "niederrhein-emergency", "ms": 24, "embedding_model": "bge-small-en-v1.5"} |
| } |
| ``` |
|
|
| #### Errors |
|
|
| - `not_found` β corpus does not exist |
| - `bad_request` β k > 20 |
|
|
| ### 4.5 `rag.ingest@1.0` |
|
|
| - **Purpose**: Add a document to a corpus. |
| - **Trust**: trusted (corpus pollution is a real risk) |
| - **Idempotency**: by content hash (`doc_cid`) |
|
|
| #### Request |
|
|
| ```json |
| { |
| "params": {"corpus": "niederrhein-emergency"}, |
| "input": { |
| "doc_cid": "blake3:...", // required; document must already be in blob store |
| "title": "Notfall-Trinkwasser", |
| "language": "de", |
| "metadata": {"author": "...", "year": 2024} |
| } |
| } |
| ``` |
|
|
| #### Response |
|
|
| ```json |
| { |
| "output": { |
| "doc_cid": "blake3:...", |
| "chunks_indexed": 87, |
| "tokens_indexed": 18342 |
| }, |
| "meta": {"corpus": "niederrhein-emergency", "ms": 4210, "ingest_event_id": "01HXR..."} |
| } |
| ``` |
|
|
| The ingest also publishes a `rag.document.ingested` event (Β§7). |
|
|
| #### Errors |
|
|
| - `not_found` β `doc_cid` not resolvable to a blob |
| - `bad_request` β unsupported media type |
|
|
| ### 4.6 `rag.list_corpora@1.0` |
| |
| #### Request |
| |
| ```json |
| {"params": {}, "input": {}} |
| ``` |
| |
| #### Response |
| |
| ```json |
| { |
| "output": { |
| "corpora": [ |
| {"name": "niederrhein-emergency", "docs": 6, "chunks": 412, "size_bytes": 18243842, "language_majority": "de"} |
| ] |
| }, |
| "meta": {"ms": 2} |
| } |
| ``` |
| |
| ### 4.7 `file.read@1.0` |
| |
| - **Purpose**: Fetch a single chunk by CID, or a whole blob via streaming chunks. |
| - **Trust**: member |
| - **Stream**: yes (chunk frames) |
| |
| #### Request |
| |
| ```json |
| { |
| "params": {}, |
| "input": {"cid": "blake3:..."} // either a chunk CID or a blob manifest CID |
| } |
| ``` |
| |
| #### Response (single chunk, non-stream) |
| |
| If `cid` resolves to a chunk: |
| |
| ```json |
| { |
| "output": { |
| "cid": "blake3:...", |
| "size_bytes": 262144, |
| "data_b64": "..." // chunk bytes, base64 |
| }, |
| "meta": {"ms": 5} |
| } |
| ``` |
| |
| #### Stream frames (manifest CID, multi-chunk) |
|
|
| If `cid` resolves to a blob manifest: |
|
|
| ``` |
| event: manifest |
| data: {"cid":"blake3:...","size_bytes":4824711,"chunk_size_bytes":262144,"chunks":[{"i":0,"cid":"blake3:..."}, ...]} |
| |
| event: chunk |
| data: {"i":0,"cid":"blake3:...","size_bytes":262144,"data_b64":"..."} |
| |
| event: chunk |
| data: {"i":1,"cid":"blake3:...","size_bytes":262144,"data_b64":"..."} |
| |
| event: done |
| data: {"chunks":19,"ms":4218} |
| ``` |
|
|
| Clients verify each chunk's BLAKE3 before storing. |
|
|
| #### Errors |
|
|
| - `not_found` β server does not have this CID |
|
|
| ### 4.8 `file.list@1.0` |
|
|
| #### Request |
|
|
| ```json |
| {"params": {}, "input": {"prefix": "blake3:abc"}} // prefix optional |
| ``` |
|
|
| #### Response |
|
|
| ```json |
| {"output": {"cids": ["blake3:abc...", "blake3:abd..."]}, "meta": {"ms": 3}} |
| ``` |
|
|
| ### 4.9 `file.advertise@1.0` |
|
|
| - **Purpose**: Used during gossip sync; one node tells another it now holds a CID. |
| - **Trust**: member |
| - **Idempotency**: yes |
|
|
| #### Request |
|
|
| ```json |
| {"params": {}, "input": {"cids": ["blake3:..."]}} |
| ``` |
|
|
| #### Response |
|
|
| ```json |
| {"output": {"recorded": 1}, "meta": {"ms": 1}} |
| ``` |
|
|
| ### 4.10 `file.put@1.0` |
|
|
| - **Purpose**: Offer a blob to a remote node (typically used to share an emergency PDF widely). |
| - **Trust**: trusted |
| - **Stream**: yes (client-stream of chunks) |
|
|
| #### Request β initial frame |
|
|
| ```json |
| {"params": {}, "input": {"manifest": {"cid":"blake3:...", "size_bytes":..., "chunks":[...]}}} |
| ``` |
|
|
| 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`. |
|
|
| ``` |
| event: ready |
| data: {"needed":[0,1,2,3, ...]} |
| |
| (client sends:) |
| event: chunk |
| data: {"i":0,"cid":"blake3:...","data_b64":"..."} |
| |
| (server:) |
| event: done |
| data: {"received":4,"ms":1832} |
| ``` |
|
|
| #### Errors |
|
|
| - `unauthorized` β caller not trusted |
| - `capacity_exceeded` β disk full or GC threshold reached |
|
|
| ### 4.11 `market.list@1.0` |
|
|
| - **Purpose**: List current (non-expired) marketplace posts in this community. |
| - **Trust**: member |
| - **Idempotency**: yes (snapshot read) |
|
|
| #### Request |
|
|
| ```json |
| { |
| "params": {}, |
| "input": { |
| "category": "offer", // optional: "offer" | "request" | "info" | "emergency" |
| "tags": ["wasser"], // optional |
| "since_lamport": 4000, // optional, for delta sync |
| "limit": 50 // optional, default 50, max 500 |
| } |
| } |
| ``` |
|
|
| #### Response |
|
|
| ```json |
| { |
| "output": { |
| "posts": [ |
| { |
| "event_id": "01HXR...", |
| "lamport": 4218, |
| "author": "ed25519:...", |
| "category": "request", |
| "title": "Suche Wasserkanister, 20L", |
| "body": "...", |
| "location": {"lat": 51.5, "lng": 6.2, "label": "Issum"}, |
| "tags": ["wasser","notfall"], |
| "created_at": "2026-05-26T08:14:22Z", |
| "expires_at": "2026-05-27T08:14:22Z" |
| } |
| ], |
| "max_lamport": 4231 |
| }, |
| "meta": {"ms": 8} |
| } |
| ``` |
|
|
| ### 4.12 `market.post@1.0` |
|
|
| - **Purpose**: Create a marketplace post. |
| - **Trust**: member |
| - **Idempotency**: yes, by `client_id` (caller-generated UUID) |
|
|
| #### Request |
|
|
| ```json |
| { |
| "params": {}, |
| "input": { |
| "client_id": "01HXR...", // required, used for dedup |
| "category": "request", |
| "title": "Suche Wasserkanister, 20L", |
| "body": "Brauche bis morgen ...", |
| "location": {"lat": 51.5, "lng": 6.2, "label": "Issum"}, |
| "tags": ["wasser","notfall"], |
| "ttl_seconds": 86400 // optional, default 7 days, max 30 days |
| } |
| } |
| ``` |
|
|
| #### Response |
|
|
| ```json |
| { |
| "output": {"event_id": "01HXR...", "lamport": 4218}, |
| "meta": {"ms": 6} |
| } |
| ``` |
|
|
| The post emits a `market.post.created` event (Β§7). |
|
|
| ### 4.13 `market.expire@1.0` |
|
|
| #### Request |
|
|
| ```json |
| { |
| "params": {}, |
| "input": { |
| "client_id": "01HXR...", // dedup |
| "event_id": "01HXR...", // the original post's id |
| "reason": "fulfilled" // "fulfilled" | "withdrawn" | "user_request" | "stale" |
| } |
| } |
| ``` |
|
|
| #### Response |
|
|
| ```json |
| {"output": {"event_id": "01HXS...", "lamport": 4252}, "meta": {"ms": 3}} |
| ``` |
|
|
| #### Errors |
|
|
| - `unauthorized` β caller is not the original author and not a trusted moderator |
| - `not_found` β original post not found |
|
|
| ### 4.14 `market.search@1.0` |
|
|
| - **Purpose**: Semantic search across posts using embeddings. |
| - **Trust**: member |
|
|
| #### Request |
|
|
| ```json |
| { |
| "params": {}, |
| "input": { |
| "query": "wasser notfall kanister", |
| "k": 10 |
| } |
| } |
| ``` |
|
|
| #### Response |
|
|
| Same shape as `market.list` but ordered by semantic similarity. Each post has an additional `score` field. |
|
|
| ### 4.15 `chat.send@1.0` |
|
|
| - **Purpose**: Send a direct message to one recipient. |
| - **Trust**: member |
| - **Idempotency**: yes, by `client_id` |
|
|
| #### Request |
|
|
| ```json |
| { |
| "params": {}, |
| "input": { |
| "client_id": "01HXR...", |
| "recipient": "ed25519:...", // recipient NodeID (full form) |
| "body": "Hi, hast du heute Strom?", |
| "attachments": [ // optional |
| {"cid": "blake3:...", "name": "schaltplan.pdf"} |
| ] |
| } |
| } |
| ``` |
|
|
| #### Response |
|
|
| ```json |
| {"output": {"event_id": "01HXR...", "lamport": 4301, "delivered": "direct"}, "meta": {"ms": 4}} |
| ``` |
|
|
| `delivered` is `"direct"` if recipient is online, `"forwarded"` if held by store-and-forward, `"queued"` if no anchor is willing. |
|
|
| ### 4.16 `chat.history@1.0` |
|
|
| - **Purpose**: Retrieve local chat history with one peer. |
| - **Trust**: self only β node returns only its own conversations |
| - **Idempotency**: yes |
|
|
| #### Request |
|
|
| ```json |
| { |
| "params": {}, |
| "input": { |
| "peer": "ed25519:...", // optional; if omitted, return all peers |
| "since_lamport": 4000, |
| "limit": 200 |
| } |
| } |
| ``` |
|
|
| #### Response |
|
|
| ```json |
| { |
| "output": { |
| "messages": [ |
| { |
| "event_id": "01HXR...", |
| "lamport": 4301, |
| "from": "ed25519:...", |
| "to": "ed25519:...", |
| "body": "...", |
| "attachments": [], |
| "created_at": "2026-05-26T08:14:22Z", |
| "delivered_at": "2026-05-26T08:14:23Z", |
| "read_at": "2026-05-26T08:15:00Z" |
| } |
| ] |
| }, |
| "meta": {"ms": 5} |
| } |
| ``` |
|
|
| ### 4.17 `community.invite@1.0` |
|
|
| - **Purpose**: Invite a new device into the community. |
| - **Trust**: member with the `can_invite` policy bit |
| - **Idempotency**: yes, by `invitee_node_id` |
|
|
| #### Request |
|
|
| ```json |
| { |
| "params": {}, |
| "input": { |
| "invitee_node_id": "ed25519:...", // full pubkey |
| "display_name": "Hannes' Tablet", |
| "initial_level": "member", // "member" or "trusted" |
| "expires_at": "2026-05-27T00:00:00Z" |
| } |
| } |
| ``` |
|
|
| #### Response |
|
|
| ```json |
| { |
| "output": { |
| "invite_blob": "ed25519:<base64-url-nopad>" // a signed, scannable invite, encoded for QR |
| }, |
| "meta": {"event_id": "01HXR...", "lamport": 4310, "ms": 3} |
| } |
| ``` |
|
|
| The invite produces a `community.member.invited` event. The invitee redeems it locally; redemption creates a `community.member.joined` event. |
|
|
| ### 4.18 `community.revoke@1.0` |
|
|
| - **Purpose**: Remove a member. Requires 3 trusted-member signatures over the same revocation payload. |
| - **Trust**: see signature requirements |
|
|
| #### Request |
|
|
| ```json |
| { |
| "params": {}, |
| "input": { |
| "client_id": "01HXR...", |
| "target_node_id": "ed25519:...", |
| "reason": "compromised|inactive|policy_violation|other", |
| "co_signers": [ |
| {"node_id": "ed25519:...", "signature": "ed25519:..."}, |
| {"node_id": "ed25519:...", "signature": "ed25519:..."}, |
| {"node_id": "ed25519:...", "signature": "ed25519:..."} |
| ] |
| } |
| } |
| ``` |
|
|
| 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. |
|
|
| #### Response |
|
|
| ```json |
| {"output": {"event_id": "01HXR...", "lamport": 4400}, "meta": {"ms": 7}} |
| ``` |
|
|
| --- |
|
|
| ## 5. Wire format |
|
|
| ### 5.1 Request |
|
|
| ```http |
| POST /bus/v1/call HTTP/1.1 |
| Host: <host>:<port> |
| Content-Type: application/json |
| Accept: application/json, text/event-stream |
| X-HearthNet-Capability: <capability_name> |
| X-HearthNet-Capability-Version: <major.minor> |
| X-HearthNet-Request-Id: <trace_id_ulid> |
| X-HearthNet-From: <full_node_id> |
| X-HearthNet-Community: <full_community_id> |
| X-HearthNet-Timestamp: <wall_clock> |
| X-HearthNet-Signature: <ed25519_signature> |
| |
| <JSON body> |
| ``` |
|
|
| The signature covers the canonical JSON of: |
|
|
| ```json |
| { |
| "capability": "...", |
| "version": "1.0", |
| "request_id": "...", |
| "from": "...", |
| "community": "...", |
| "timestamp": "...", |
| "body": <request body, canonicalised> |
| } |
| ``` |
|
|
| Servers verify by reconstructing this object and checking the signature against the caller's pubkey (derived from `X-HearthNet-From`). |
|
|
| ### 5.2 Response β non-stream |
|
|
| ```http |
| HTTP/1.1 200 OK |
| Content-Type: application/json |
| X-HearthNet-Request-Id: <trace_id> |
| X-HearthNet-From: <server_node_id> |
| X-HearthNet-Timestamp: <wall_clock> |
| X-HearthNet-Signature: <ed25519 over response> |
| |
| <JSON body> |
| ``` |
|
|
| 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. |
|
|
| ### 5.3 Response β stream |
|
|
| ```http |
| HTTP/1.1 200 OK |
| Content-Type: text/event-stream |
| X-HearthNet-Request-Id: <trace_id> |
| X-HearthNet-From: <server_node_id> |
| |
| event: <event_name> |
| data: <JSON, single line> |
| |
| event: <event_name> |
| data: <JSON> |
| ... |
| ``` |
|
|
| Frame events: |
|
|
| - `token`, `tool_call_delta`, `chunk`, `manifest`, `ready` β capability-specific |
| - `progress` β `data: {"current": N, "total": M, "stage": "..."}` (any capability) |
| - `ack` β `data: {"upto": N}` (client β server backpressure) |
| - `error` β terminal error frame, replaces `done` |
| - `done` β terminal success frame |
|
|
| Every stream ends with exactly one of `done` or `error`. After that the connection closes. |
|
|
| ### 5.4 Error response |
|
|
| ```http |
| HTTP/1.1 <status> |
| Content-Type: application/json |
| X-HearthNet-Request-Id: <trace_id> |
| |
| { |
| "error": "<code>", |
| "message": "<human-readable, optional>", |
| "retry_after_ms": 2000, |
| "alt_capabilities": ["llm.chat@0.9"], |
| "alt_nodes": ["ed25519:..."], |
| "schema_hash_expected": "blake3:..." // only for schema_mismatch |
| } |
| ``` |
|
|
| ### 5.5 Status code mapping |
|
|
| | Status | Error codes | |
| |--------|-------------| |
| | 200 | (success) | |
| | 400 | `bad_request`, `schema_mismatch` | |
| | 401 | `invalid_signature`, `unauthorized` | |
| | 403 | `revoked` | |
| | 404 | `not_found` | |
| | 408 | `timeout` | |
| | 410 | `expired` | |
| | 429 | `rate_limited`, `capacity_exceeded` | |
| | 500 | `internal_error` | |
| | 501 | `not_implemented` | |
| | 503 | `partition` | |
|
|
| For streams, an `error` frame replaces these; the HTTP status is 200 because the stream was accepted. |
|
|
| --- |
|
|
| ## 6. Manifests |
|
|
| ### 6.1 Node manifest |
|
|
| ```json |
| { |
| "version": 1, |
| "contract_version": "1.0", |
| "node_id": "ed25519:<full_pubkey>", |
| "display_name": "garage-pc", |
| "community_id": "ed25519:<full_pubkey>", |
| "profile": "anchor", |
| "endpoints": [ |
| {"transport": "https", "host": "192.168.188.25", "port": 7080} |
| ], |
| "hardware": { |
| "gpu": "RTX 5090", |
| "vram_gb": 32, |
| "ram_gb": 128, |
| "cpu_cores": 24, |
| "disk_free_gb": 4000 |
| }, |
| "capabilities": [ |
| { |
| "name": "llm.chat", |
| "version": "1.0", |
| "stability": "stable", |
| "schema_hash": "blake3:...", |
| "params": {"model": "qwen2.5-7b-instruct", "quant": "q4_k_m", "ctx": 8192}, |
| "max_concurrent": 4 |
| } |
| ], |
| "uptime_seconds": 43210, |
| "load": {"cpu": 0.12, "vram_used_gb": 6.4, "in_flight_total": 0}, |
| "issued_at": "2026-05-26T08:14:22Z", |
| "expires_at": "2026-05-26T08:14:52Z", |
| "signature": "ed25519:..." |
| } |
| ``` |
|
|
| #### Rules |
|
|
| - `expires_at - issued_at == 30 s` exactly |
| - Re-issued every `MANIFEST_REPUBLISH_INTERVAL_SECONDS` (20s) |
| - Signed by the node's device key |
| - Stale manifests (past `expires_at`) are rejected with `expired` |
| - 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` |
|
|
| ### 6.2 Community manifest |
|
|
| ```json |
| { |
| "version": 1, |
| "community_id": "ed25519:<root_pubkey>", |
| "name": "Niederrhein Demo", |
| "root_key": "ed25519:<root_pubkey>", |
| "created_at": "2026-05-26T08:00:00Z", |
| "lamport_at_creation": 0, |
| "policy": { |
| "min_signatures_to_invite": 1, |
| "min_signatures_to_demote": 3, |
| "min_signatures_to_revoke": 3, |
| "capability_token_ttl_seconds": 86400, |
| "federation_enabled": true, |
| "default_member_can_invite": true |
| }, |
| "members": [ |
| { |
| "node_id": "ed25519:...", |
| "level": "anchor", |
| "added_at": "2026-05-26T08:00:00Z", |
| "added_by": "ed25519:..." |
| } |
| ], |
| "revoked": [ |
| {"node_id": "ed25519:...", "revoked_at": "..."} |
| ], |
| "head_lamport": 4218, |
| "signature": "ed25519:..." |
| } |
| ``` |
|
|
| 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. |
|
|
| --- |
|
|
| ## 7. Events (the community log) |
|
|
| ### 7.1 Common event envelope |
|
|
| ```json |
| { |
| "schema_version": 1, |
| "event_id": "01HXR...", // ULID |
| "lamport": 4218, |
| "wall_clock": "2026-05-26T08:14:22Z", |
| "community_id": "ed25519:...", |
| "author": "ed25519:...", |
| "event_type": "market.post.created", |
| "data": { /* type-specific */ }, |
| "signature": "ed25519:..." // over canonical JSON of all above |
| } |
| ``` |
|
|
| ### 7.2 Canonical event types |
|
|
| For each: `data` schema, who may produce, who consumes. |
|
|
| #### `community.created` |
|
|
| ```json |
| { |
| "name": "Niederrhein Demo", |
| "founder_node_id": "ed25519:...", |
| "policy": { /* full policy as in community manifest */ } |
| } |
| ``` |
|
|
| Producer: founder, exactly once at community birth. |
| Consumer: all. |
|
|
| #### `community.member.invited` |
|
|
| ```json |
| { |
| "invitee_node_id": "ed25519:...", |
| "display_name": "Hannes' Tablet", |
| "initial_level": "member", |
| "expires_at": "2026-05-27T00:00:00Z" |
| } |
| ``` |
|
|
| Producer: any member with `can_invite`. |
| Consumer: all. |
|
|
| #### `community.member.joined` |
|
|
| ```json |
| { |
| "invite_event_id": "01HXR...", |
| "node_manifest": { /* full manifest at join time */ } |
| } |
| ``` |
|
|
| Producer: the invitee, on first connection. |
| Consumer: all. |
|
|
| #### `community.member.revoked` |
|
|
| ```json |
| { |
| "target_node_id": "ed25519:...", |
| "reason": "compromised", |
| "co_signers": [{"node_id":"...", "signature":"..."}, ...] |
| } |
| ``` |
|
|
| Producer: any trusted member who has gathered 3 co-signatures. |
| Consumer: all. |
|
|
| #### `community.member.promoted` / `community.member.demoted` |
|
|
| ```json |
| { |
| "target_node_id": "ed25519:...", |
| "new_level": "trusted", |
| "co_signers": [...] |
| } |
| ``` |
|
|
| Producer: trusted member with required signatures (1 promote, 3 demote). |
| Consumer: all. |
|
|
| #### `community.policy.updated` |
|
|
| ```json |
| { |
| "policy": { /* new policy */ } |
| } |
| ``` |
|
|
| Producer: root key only. |
| Consumer: all. |
|
|
| #### `node.manifest.updated` |
|
|
| ```json |
| { |
| "manifest": { /* full node manifest */ } |
| } |
| ``` |
|
|
| Producer: each node, advisory; not strictly required in the log but useful for replay-based audit. |
| Consumer: all. |
|
|
| #### `market.post.created` |
|
|
| ```json |
| { |
| "client_id": "...", |
| "category": "request", |
| "title": "...", |
| "body": "...", |
| "location": {"lat":..., "lng":..., "label":"..."}, |
| "tags": ["..."], |
| "ttl_seconds": 86400 |
| } |
| ``` |
|
|
| Producer: any member. |
| Consumer: all. |
|
|
| #### `market.post.updated` |
|
|
| ```json |
| { |
| "client_id": "...", |
| "target_event_id": "01HXR...", |
| "fields": {"body": "..."} |
| } |
| ``` |
|
|
| Producer: original author only. |
| Consumer: all. |
|
|
| #### `market.post.expired` |
|
|
| ```json |
| { |
| "client_id": "...", |
| "target_event_id": "01HXR...", |
| "reason": "fulfilled|withdrawn|user_request|stale" |
| } |
| ``` |
|
|
| Producer: original author OR any trusted moderator (with `reason != "user_request"`). |
| Consumer: all. |
|
|
| #### `chat.message.sent` |
|
|
| ```json |
| { |
| "client_id": "...", |
| "recipient": "ed25519:...", |
| "body": "...", |
| "attachments": [{"cid":"...","name":"..."}] |
| } |
| ``` |
|
|
| Producer: sender. |
| Consumer: sender + recipient (others may see envelope only, but `data` MAY be encrypted in Phase 2). |
|
|
| #### `chat.message.delivered` |
|
|
| ```json |
| {"target_event_id": "01HXR...", "delivered_at": "..."} |
| ``` |
|
|
| Producer: recipient. |
| Consumer: sender. |
|
|
| #### `chat.message.read` |
|
|
| ```json |
| {"target_event_id": "01HXR...", "read_at": "..."} |
| ``` |
|
|
| Producer: recipient (optional, may be disabled by user). |
| Consumer: sender. |
|
|
| #### `file.cid.advertised` |
|
|
| ```json |
| {"cid": "blake3:...", "sizes_bytes": 4824711} |
| ``` |
|
|
| Producer: holder. |
| Consumer: all (used for fan-out file discovery). |
|
|
| #### `file.cid.unpinned` |
|
|
| ```json |
| {"cid": "blake3:..."} |
| ``` |
|
|
| Producer: holder. |
| Consumer: all. |
|
|
| #### `rag.document.ingested` |
|
|
| ```json |
| { |
| "corpus": "niederrhein-emergency", |
| "doc_cid": "blake3:...", |
| "title": "...", |
| "language": "de", |
| "chunks": 87 |
| } |
| ``` |
|
|
| Producer: ingester. |
| Consumer: all members. |
|
|
| #### `federation.peer.added` / `federation.peer.removed` (Phase 2) |
|
|
| Reserved. |
|
|
| ### 7.3 Lamport rules |
|
|
| Every node maintains a per-community Lamport counter. |
|
|
| ``` |
| on send: lamport_send = ++lamport |
| on receive: lamport = max(lamport, received.lamport) + 1 |
| ``` |
|
|
| ### 7.4 Ordering |
|
|
| - Replay order: by `lamport` ascending, tie-broken by `event_id` ascending (ULIDs sort by time naturally) |
| - Conflict resolution: last-writer-wins by Lamport; `community.member.revoked` is checked first when replaying actions by the revoked party |
|
|
| ### 7.5 Snapshots |
|
|
| A snapshot at Lamport L is: |
|
|
| ```json |
| { |
| "schema_version": 1, |
| "community_id": "ed25519:...", |
| "lamport": <L>, |
| "wall_clock": "...", |
| "state": { /* materialised views: community manifest, marketplace_current, ... */ }, |
| "covers_events_up_to": <L>, |
| "signature": "ed25519:..." |
| } |
| ``` |
|
|
| Signed by any anchor. Other nodes verify the signature and the membership of the signer. |
|
|
| ### 7.6 Sync protocol (gossip) |
|
|
| Two nodes meeting: |
|
|
| 1. A β B: `GET /sync/v1/heads` β returns `{community_id: max_lamport}` per known community |
| 2. A computes delta; for each community where A is ahead, A β B: `POST /sync/v1/events` with all events `lamport > B.head` |
| 3. B verifies signatures, applies, returns `{accepted, rejected, new_head_lamport}` |
| 4. Roles reverse; B sends what A is missing |
|
|
| This is also covered in [X02 Β§6](cross-cutting/X02-events.md). |
|
|
| --- |
|
|
| ## 8. Pub-sub topics |
|
|
| Topics are used for live notifications between connected peers (in addition to durable events in the log). |
|
|
| | Topic | Payload | Producer | Subscriber | |
| |-------|---------|----------|------------| |
| | `community.member.added` | event envelope | any member | all | |
| | `community.member.revoked` | event envelope | trusted | all | |
| | `node.manifest.updated` | manifest | each node | all | |
| | `marketplace.post.created` | event envelope | any member | all | |
| | `marketplace.post.expired` | event envelope | author or trusted | all | |
| | `chat.message.<recipient>` | event envelope | sender | recipient | |
| | `emergency.mode.changed` | `{online: bool, since: "..."}` | each node locally | local UI only β never on the wire | |
| | `federation.peer.added` | event envelope | anchor | all anchors | |
| | `capability.registered` | descriptor | local bus | local UI only | |
| | `capability.deregistered` | name+version | local bus | local UI only | |
|
|
| Transport: HTTP long-polling for MVP (`GET /pubsub/v1/subscribe?topic=...`), WebSocket in Phase 2. |
|
|
| --- |
|
|
| ## 9. Error codes (complete reference) |
|
|
| | Code | When | Retry? | |
| |------|------|--------| |
| | `bad_request` | Malformed payload | no, fix and resend | |
| | `schema_mismatch` | Schema hash differs | no, upgrade and resend | |
| | `invalid_signature` | Signature verification failed | no | |
| | `unauthorized` | Caller lacks required trust level | no | |
| | `revoked` | Caller's NodeID is revoked | no | |
| | `expired` | Manifest or token past `expires_at` | no, re-issue and resend | |
| | `not_found` | Resource doesn't exist | no | |
| | `not_implemented` | Capability declared but unimplemented | no | |
| | `timeout` | Exceeded server-side deadline | yes, with backoff | |
| | `partition` | Peer unreachable | yes, with backoff | |
| | `capacity_exceeded` | Concurrent limit reached | yes, honour `retry_after_ms` | |
| | `rate_limited` | Rate budget exceeded | yes, honour `retry_after_ms` | |
| | `internal_error` | Server-side bug or crash | maybe, idempotent capabilities only | |
|
|
| --- |
|
|
| ## 10. Signing reference |
|
|
| ```python |
| # canonical_json(obj) β bytes (sorted keys, no whitespace, no trailing zeros on floats) |
| |
| def sign(payload: dict, sk: SigningKey) -> dict: |
| p = {k: v for k, v in payload.items() if k != "signature"} |
| msg = canonical_json(p) |
| sig = sk.sign(msg).signature |
| p["signature"] = f"ed25519:{base64url_nopad(sig)}" |
| return p |
| |
| def verify(payload: dict, vk: VerifyKey) -> bool: |
| sig_field = payload.get("signature", "") |
| if not sig_field.startswith("ed25519:"): |
| return False |
| sig = base64url_nopad_decode(sig_field[len("ed25519:"):]) |
| p = {k: v for k, v in payload.items() if k != "signature"} |
| msg = canonical_json(p) |
| try: |
| vk.verify(msg, sig) |
| return True |
| except BadSignature: |
| return False |
| ``` |
|
|
| For HTTP requests, the signed payload is the synthetic envelope of Β§5.1, not the raw body. |
|
|
| --- |
|
|
| ## 11. Schema hash computation |
|
|
| For a capability descriptor, the schema hash is BLAKE3 of the canonical JSON of: |
|
|
| ```json |
| { |
| "name": "<capability_name>", |
| "version": "<major.minor>", |
| "request_schema": <JSON Schema>, |
| "response_schema": <JSON Schema or null>, |
| "stream_schema": <JSON Schema or null> |
| } |
| ``` |
|
|
| 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. |
|
|
| --- |
|
|
| ## 12. Open questions tracked here |
|
|
| 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. |
| 2. **Multi-party group chat** β out of scope this release. Reserved event type `chat.group.*`. |
| 3. **Federation manifest** β when added, will look like a community manifest with `peers: []` field. Reserved. |
| 4. **WebSocket upgrade** β when added, the `/bus/v1/call` endpoint will accept an `Upgrade: websocket` header; behaviour is otherwise the same. |
| 5. **Tool calls in `llm.chat`** β declared as Phase 2; the stream frame `tool_call_delta` is reserved. |
|
|
| --- |
|
|
| *End of HearthNet Capability Contract v1.0.* |
|
|