Spaces:
Running on Zero
HearthNet Capability Contract — Phase 2 additions (v2.0)
Spec version: v2.0
Last touched: 2026-06-09
Builds on: ../CAPABILITY_CONTRACT.md (v1.0)
This document is additive to v1.0. Everything in v1.0 still holds unless explicitly overridden here. Bumping a node's contract_version to "2.0" means: "I implement all of v1 plus the additions below."
1. Conventions delta
1.1 New encoded forms
- Token format:
hntoken://v1/<base64-url-nopad of canonical-JSON of token body + signature>. See M16 §3. - Federation peering blob:
hnfed://v1/<base64>— analogous to invite blob, signed by both community roots (cross-sig). - Encrypted payload header: when a chat body is E2E-encrypted, the event's
data.bodybecomes a{"e2e": true, "header": {...}, "ciphertext": "<base64>"}object. See M23 §4.
1.2 Sign-over-method choice
Phase 2 capability tokens use the JWS-flavoured envelope, not the canonical-JSON envelope. Rationale: tokens are short-lived and frequently passed through HTTP intermediaries; JWS is the lingua franca.
hntoken_envelope = base64url(header) + "." + base64url(payload) + "." + base64url(signature)
Both forms continue to use Ed25519.
1.3 New error codes (additive)
| Code | Meaning |
|---|---|
federation_forbidden |
The caller's community is not federated with ours for this capability |
token_invalid |
Token signature failed |
token_expired |
Token past exp |
token_scope_insufficient |
Token does not grant this capability |
relay_unreachable |
Configured relay tier is down |
e2e_session_missing |
Caller did not establish an X3DH session before sending encrypted message |
e2e_decrypt_failed |
Ciphertext could not be decrypted (key mismatch, ratchet drift) |
dht_lookup_failed |
DHT lookup timed out before finding sources |
not_federated |
Federation manifest does not exist between these communities |
2. Capability namespace — Phase 2 stable set
Promoted from "reserved" in v1.0:
| Prefix | Now | Defined |
|---|---|---|
federation.* |
stable | M14 |
ocr.* |
stable | M17 |
trans.* |
stable | M18 |
stt.* tts.* |
stable | M19 |
img.* |
stable | M20 |
rerank.* |
stable | M24 |
chat.thread.* |
stable | M25 |
chat.forward.* |
stable | M14 (via relay) |
auth.* |
stable (new) | M16 |
3. Complete new capabilities list
| Name | Stability | Stream? | Trust required | Section |
|---|---|---|---|---|
federation.peer.add@1.0 |
stable | no | anchor (with co-sig) | §4.1 |
federation.peer.remove@1.0 |
stable | no | anchor (with co-sig) | §4.2 |
federation.peer.list@1.0 |
stable | no | member | §4.3 |
federation.proxy@1.0 |
stable | yes | federated | §4.4 |
auth.token.issue@1.0 |
stable | no | member | §4.5 |
auth.token.revoke@1.0 |
stable | no | issuer or trusted | §4.6 |
auth.token.introspect@1.0 |
stable | no | self | §4.7 |
ocr.image@1.0 |
stable | no | member | §4.8 |
ocr.pdf@1.0 |
stable | yes (progress) | trusted | §4.9 |
trans.text@1.0 |
stable | no | member | §4.10 |
stt.transcribe@1.0 |
stable | yes (segments) | member | §4.11 |
tts.synthesize@1.0 |
stable | yes (audio chunks) | member | §4.12 |
img.describe@1.0 |
stable | no | member | §4.13 |
img.generate@1.0 |
stable | yes (progress) | trusted | §4.14 |
rerank.text@1.0 |
stable | no | member | §4.15 |
chat.thread.create@1.0 |
stable | no | member | §4.16 |
chat.thread.send@1.0 |
stable | no | thread member | §4.17 |
chat.thread.history@1.0 |
stable | no | thread member | §4.18 |
chat.thread.leave@1.0 |
stable | no | thread member | §4.19 |
chat.forward.put@1.0 |
stable | yes | anchor with forward | §4.20 |
chat.forward.fetch@1.0 |
stable | yes | self | §4.21 |
file.put.resume@1.0 |
stable | yes | trusted | §4.22 |
llm.chat@2.0 (UPDATE) |
stable | yes | member | §4.23 |
llm.tools.call@1.0 (NEW, used by llm.chat tool flow) |
stable | no | member | §4.24 |
4. Per-capability specifications
4.1 federation.peer.add@1.0
Establish a federation link with another community.
Request:
{
"params": {},
"input": {
"client_id": "01HXR...",
"peer_community_id": "ed25519:<other community root pubkey>",
"peer_endpoints": [{"transport":"https","host":"...","port":7080}],
"co_signers": [{"node_id":"...","signature":"..."}, "...", "..."],
"scope": {
"capabilities": ["rag.query","market.list"],
"data_visibility":"public_corpora_only"
},
"expires_at": "2027-06-09T00:00:00Z"
}
}
co_signers requires policy.min_signatures_to_federate (new policy field; default 3, see M14 §5). The remote community must also have us in their federation manifest before federated calls work.
Response:
{"output": {"event_id": "01HXS...", "federation_id": "ed25519:A:ed25519:B"}, "meta": {"ms": 14}}
Emits federation.peer.added event.
Errors: unauthorized, bad_request, not_found (peer endpoints unreachable).
4.2 federation.peer.remove@1.0
Terminate a federation link.
Request:
{
"params": {},
"input": {
"client_id": "01HXR...",
"peer_community_id": "ed25519:...",
"reason": "policy_violation|unused|mutual",
"co_signers": [...]
}
}
Emits federation.peer.removed.
4.3 federation.peer.list@1.0
List active federations.
Response:
{
"output": {
"peers": [
{
"community_id": "ed25519:...",
"name": "Geldern Demo",
"scope": {"capabilities":["rag.query"]},
"established_at": "...",
"expires_at": "...",
"last_heartbeat": "..."
}
]
},
"meta": {"ms": 2}
}
4.4 federation.proxy@1.0
A federated peer asks our community to forward a capability call to one of our members. This is how cross-community RAG query works: peer's anchor calls federation.proxy on our anchor, which then internally routes to rag.query on whichever local node has the corpus.
Request:
{
"params": {"target_capability": "rag.query@1.0"},
"input": {
"client_id": "01HXR...",
"token": "hntoken://v1/...",
"body": { /* the body of the underlying capability */ }
}
}
Response: Whatever the target capability returns. Streams pass through transparently.
The proxy verifies the token's scope includes target_capability. Returns federation_forbidden otherwise.
4.5 auth.token.issue@1.0
Issue a capability token.
Request:
{
"params": {},
"input": {
"client_id": "01HXR...",
"subject": "ed25519:<recipient NodeID>",
"scope": {
"capabilities": ["rag.query@1.0", "embed.text@1.0"],
"corpora": ["niederrhein-emergency"],
"rate_limit_per_minute": 60
},
"ttl_seconds": 3600,
"audience": "ed25519:<community_id where token is presented, optional>"
}
}
Response:
{
"output": {"token": "hntoken://v1/eyJhbGc...", "token_id": "01HXS..."},
"meta": {"ms": 4}
}
See M16 for token body schema.
4.6 auth.token.revoke@1.0
Revoke a previously-issued token.
Request:
{"params": {}, "input": {"client_id":"01HXR...","token_id":"01HXR..."}}
Emits auth.token.revoked event.
4.7 auth.token.introspect@1.0
Self-only: check whether a token is still valid.
Request: {"params":{},"input":{"token":"hntoken://v1/..."}}
Response: {"output":{"active":bool,"scope":{...},"expires_at":"..."},"meta":{...}}
4.8 ocr.image@1.0
Extract text from a single image.
Request:
{
"params": {"backend": "tesseract", "languages": ["deu","eng"]},
"input": {
"image_cid": "blake3:...",
"preprocess": {"deskew": true, "denoise": false}
}
}
Response:
{
"output": {
"text": "Trinkwasser ohne Strom ...",
"blocks": [
{"text":"Trinkwasser ohne Strom","bbox":[10,20,300,40],"confidence":0.94}
],
"language": "de"
},
"meta": {"backend":"tesseract","ms":820}
}
4.9 ocr.pdf@1.0
Extract text from a (scanned) PDF. Streams per-page progress.
Request:
{
"params": {"backend":"multilingual","languages":["deu","lat"]},
"input": {
"doc_cid": "blake3:...",
"page_range": [1, 50],
"preprocess": {"deskew": true},
"store_text": true
}
}
Stream frames:
event: progress
data: {"current": 3, "total": 12, "stage": "OCRing page 3"}
event: page
data: {"page": 3, "text": "...", "confidence_mean": 0.91}
event: done
data: {"pages": 12, "stored_cid": "blake3:...", "ms": 18342}
If store_text:true, the extracted text is stored as a new blob and its CID returned. Useful for piping into rag.ingest.
4.10 trans.text@1.0
Translate between languages.
Request:
{
"params": {"backend":"nllb"},
"input": {
"text": "Brauche Wasserkanister",
"from": "de",
"to": "en",
"domain": "everyday"
}
}
Response:
{
"output": {"text":"Need water canister", "confidence": 0.97},
"meta": {"backend":"nllb","model":"nllb-200-distilled-600M","ms":312}
}
Plattdeutsch supported as nds. Marketplace UI offers one-click translate on a foreign-language post.
4.11 stt.transcribe@1.0
Transcribe an audio blob.
Request:
{
"params": {"backend":"whisper","model":"large-v3"},
"input": {
"audio_cid": "blake3:...",
"language": "auto",
"diarize": false,
"translate_to_en": false
}
}
Stream frames:
event: segment
data: {"start": 0.0, "end": 4.2, "text": "Hallo, ich brauche...", "language":"de"}
event: segment
data: {"start": 4.2, "end": 8.1, "text": "Hilfe mit dem Generator."}
event: done
data: {"language":"de","ms":2100,"duration_seconds":18.4}
4.12 tts.synthesize@1.0
Synthesize speech from text.
Request:
{
"params": {"backend":"xtts","voice":"hannes_v1","language":"de"},
"input": {
"text": "Das Regenwasser muss zuerst gefiltert werden.",
"speed": 1.0,
"format": "ogg_vorbis"
}
}
Stream frames:
event: chunk
data: {"i":0,"size_bytes":16384,"data_b64":"..."}
event: done
data: {"total_bytes":91247,"duration_seconds":4.2,"format":"ogg_vorbis","ms":1832}
4.13 img.describe@1.0
Describe what's in an image.
Request:
{
"params": {"backend":"florence2"},
"input": {
"image_cid": "blake3:...",
"task": "detailed_caption",
"language": "de"
}
}
task ∈ {"caption","detailed_caption","ocr","objects","tags"}.
Response:
{
"output": {
"caption": "Ein Schaltplan einer einfachen Wasserfilteranlage mit ...",
"tags": ["schaltplan","wasserfilter","skizze"],
"objects": [{"label":"pipe","bbox":[10,20,80,90]}]
},
"meta": {"backend":"florence2","ms":640}
}
4.14 img.generate@1.0
Generate an image from a text prompt.
Request:
{
"params": {"backend":"flux","model":"flux.1-dev","lora":"local-style-v1"},
"input": {
"prompt": "ein einfacher schaltplan einer wasserfilteranlage, schwarz auf weiss",
"negative_prompt": "color, photorealistic",
"width": 1024,
"height": 1024,
"steps": 20,
"seed": 12345
}
}
Stream frames:
event: progress
data: {"step":5,"total":20}
event: done
data: {"image_cid":"blake3:...","width":1024,"height":1024,"ms":12800}
4.15 rerank.text@1.0
Rerank a list of documents against a query.
Request:
{
"params": {"model":"BAAI/bge-reranker-v2-m3"},
"input": {
"query": "Wie reinige ich Regenwasser ohne Strom?",
"documents": [
{"id":"doc1","text":"..."},
{"id":"doc2","text":"..."}
],
"top_k": 10
}
}
Response:
{
"output": {
"ranked": [
{"id":"doc2","score":0.91},
{"id":"doc1","score":0.42}
]
},
"meta": {"model":"BAAI/bge-reranker-v2-m3","ms":42}
}
4.16 chat.thread.create@1.0
Create a multi-party thread.
Request:
{
"params": {},
"input": {
"client_id": "01HXR...",
"name": "Nachbarschaftshilfe Mai",
"members": ["ed25519:...","ed25519:...","ed25519:..."],
"e2e_enabled": true
}
}
Response: {"output":{"thread_id":"01HXR...","event_id":"01HXR..."},"meta":{...}}
4.17 chat.thread.send@1.0
Send to a thread. Body is E2E-encrypted when e2e_enabled.
Request:
{
"params": {"thread_id":"01HXR..."},
"input": {
"client_id": "01HXR...",
"body": "...", // cleartext or {"e2e":true,...} envelope
"attachments": [{"cid":"blake3:...","name":"..."}]
}
}
4.18 chat.thread.history@1.0
Self-only history retrieval for a thread.
Request: {"params":{"thread_id":"01HXR..."},"input":{"since_lamport":4000,"limit":200}}
4.19 chat.thread.leave@1.0
Leave a thread.
4.20 chat.forward.put@1.0
Store-and-forward: leave a chat message with an anchor for later delivery.
Stream initiator pattern identical to file.put. Anchors that opt into the role register this capability.
4.21 chat.forward.fetch@1.0
Self-only: collect queued messages from an anchor.
4.22 file.put.resume@1.0
Resume a partial PUT.
Request:
{"params":{},"input":{"manifest_cid":"blake3:...","client_id":"01HXR..."}}
Response (server tells client which chunks are missing):
event: ready
data: {"missing":[3,4,5,8]}
(client sends only those chunks)
event: done
data: {"received":4}
Server keeps partial transfer state for FILE_RESUME_PARTIAL_TTL_SECONDS (1 hour). After that, partial transfers are discarded and client must restart.
4.23 llm.chat@2.0 (update)
Backward-compatible minor bump (still name="llm.chat", callers can still ask for @>=1.0 and be matched). New optional fields:
{
"params": {"model":"...","modalities":["text","vision"]},
"input": {
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "Was siehst du?"},
{"type": "image", "image_cid": "blake3:..."}
]
}
],
"tools": [
{
"name": "rag.query",
"description": "Search the niederrhein-emergency corpus",
"parameters_schema": { /* JSON Schema for tool args */ }
}
],
"tool_choice": "auto"
}
}
4.24 llm.tools.call@1.0
When an LLM emits a tool_call_delta stream frame followed by tool_call end, the caller is responsible for executing the tool. To make this composable, the LLM service offers llm.tools.call as a convenience that wraps "execute one bus call, return its output as a tool message". Callers MAY use it; the more general flow is to have the orchestrator (UI / agent) handle it.
Request:
{
"params": {},
"input": {
"tool_call_id": "tc_01HXR...",
"target_capability":"rag.query@1.0",
"target_body": { /* the tool's args, validated against the tool's parameters_schema */ }
}
}
Response: mirrors target capability's response.
5. Wire format changes
5.1 WebSocket upgrade
For /bus/v1/call, clients MAY include:
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Protocol: hearthnet-bus.v2
Server responds with a 101 if it supports WebSocket (Phase 2 nodes do). Once upgraded, the connection is bidirectional and persistent for the life of the request — useful for tool-call loops and streaming RAG.
Frames over WebSocket are the same JSON event-name + data envelope as SSE, just delivered as binary or text WebSocket frames instead of data: lines.
See X06.
5.2 Token-bearer requests
When a caller carries a capability token instead of (or in addition to) a per-request signature:
X-HearthNet-Token: hntoken://v1/<base64>
The server validates the token (signature, expiry, scope) and uses the token's subject as the effective caller for the trust check. The token's issuer must be a member of a federated community.
If both X-HearthNet-Signature and X-HearthNet-Token are present, signature is checked first; token is used to widen scope (e.g. "the caller is a federated peer, but for this single call they presented a token granting access").
5.3 Federation routing
When a node receives a call where X-HearthNet-Community ≠ our community ID:
- Look up federation manifest for the calling community.
- If absent →
not_federated(404). - If present but scope does not include the requested capability →
federation_forbidden(403). - Else, dispatch normally; record federation usage in metrics.
6. Manifests
6.1 Federation manifest (new)
{
"schema_version": 1,
"federation_id": "<community_a>:<community_b>",
"community_a": "ed25519:...",
"community_b": "ed25519:...",
"established_at": "2026-06-09T10:00:00Z",
"expires_at": "2027-06-09T10:00:00Z",
"scope": {
"a_grants_b": {"capabilities":["rag.query"], "corpora":["public-emergency"]},
"b_grants_a": {"capabilities":["rag.query"]}
},
"bootstrap_endpoints_a": [{"transport":"https","host":"...","port":7080}],
"bootstrap_endpoints_b": [{"transport":"https","host":"...","port":7080}],
"signatures": {
"a": {"signed_by":"ed25519:<anchor of A>","signature":"...","co_signers":[{...},{...}]},
"b": {"signed_by":"ed25519:<anchor of B>","signature":"...","co_signers":[{...},{...}]}
}
}
Both sides must sign with their min_signatures_to_federate threshold. The federation manifest lives in both communities' event logs.
6.2 Token body (new)
JWS-style. Header:
{"alg":"EdDSA","typ":"hntoken","v":1}
Payload:
{
"iss": "ed25519:<issuer NodeID>",
"sub": "ed25519:<subject NodeID>",
"aud": "ed25519:<audience community, optional>",
"iat": 1717939200,
"exp": 1717942800,
"jti": "01HXR...",
"scope": {
"capabilities": ["rag.query@1.0"],
"params_constraints": {"corpus":["niederrhein-emergency"]},
"rate_limit_per_minute": 60
}
}
Signature: Ed25519 over base64url(header) + "." + base64url(payload).
6.3 Node manifest delta
Phase 2 nodes set contract_version: "2.0". Additional fields in capabilities[].params:
{
"name": "llm.chat",
"version": "2.0",
"params": {
"model": "...",
"modalities": ["text","vision"],
"tools_supported": true,
"max_tools_per_call": 16,
"requires_internet": false
}
}
7. Events (additive to v1.0 §7.2)
7.1 New event types
federation.peer.added
federation.peer.removed
federation.heartbeat
auth.token.issued
auth.token.revoked
chat.thread.created
chat.thread.member.added
chat.thread.member.removed
chat.thread.message.sent
chat.thread.message.delivered
chat.thread.archived
e2e.prekeys.published
e2e.session.established
e2e.session.broken
file.replication.scheduled
file.replication.completed
ocr.document.indexed
7.2 Selected schemas
federation.peer.added
{
"peer_community_id": "ed25519:...",
"federation_id": "...",
"scope": {...},
"co_signers": [{...},{...},{...}]
}
auth.token.issued
Stored without the signature payload (just metadata for audit):
{
"token_id": "01HXR...",
"subject": "ed25519:...",
"scope": {...},
"expires_at":"...",
"audience": "ed25519:..."
}
auth.token.revoked
{"token_id":"01HXR...","reason":"manual|policy|compromise"}
chat.thread.created
{
"thread_id": "01HXR...",
"client_id": "01HXR...",
"name": "Nachbarschaftshilfe Mai",
"members": ["ed25519:...","ed25519:..."],
"e2e_enabled": true,
"ratchet_root_pubkey": "x25519:..."
}
chat.thread.message.sent
{
"thread_id": "01HXR...",
"client_id": "01HXR...",
"body": {"e2e":true,"header":{...},"ciphertext":"..."} | "<cleartext>",
"attachments": [...]
}
e2e.prekeys.published
{
"node_id": "ed25519:...",
"identity_pubkey": "x25519:...",
"signed_prekey": {"pubkey":"x25519:...","signature":"ed25519:..."},
"one_time_prekeys": ["x25519:...","x25519:...","..."]
}
file.replication.scheduled
{
"cid": "blake3:...",
"desired_copies": 3,
"current_copies": 1,
"candidate_holders": ["ed25519:...","ed25519:..."]
}
ocr.document.indexed
{
"doc_cid": "blake3:...",
"text_cid": "blake3:...",
"pages": 12,
"languages": ["de","la"],
"ocr_backend": "multilingual"
}
7.3 Federation events propagate cross-community
Events with event_type ∈ {federation.*, auth.token.issued, auth.token.revoked} MAY be cross-published into a federated community's event log. The community receiving such an event records the originating community in data._source_community. This is the only case where an event's community_id does not equal the log it lives in.
8. Pub-sub topics (additive)
| Topic | Producer | Subscriber |
|---|---|---|
federation.peer.added |
member adding | all members |
federation.peer.heartbeat.<peer_community> |
federation client loop | UI |
auth.token.issued |
issuer | issuer + subject |
chat.thread.message.<thread_id> |
sender | thread members |
e2e.prekey.request.<our_short_id> |
sender wanting session | recipient |
e2e.session.handshake.<our_short_id> |
initiator | responder |
file.replication.request.<cid_prefix> |
replication scheduler | all anchors |
mobile.push.<device_id> |
sender | push relay tier (M15) |
9. Errors — complete delta (additive to v1.0 §9)
| Code | When | Retry? |
|---|---|---|
federation_forbidden |
Caller's community not federated for this capability | no |
not_federated |
No federation manifest with caller's community | no |
token_invalid |
Token signature bad | no |
token_expired |
Token past exp |
no, request a new token |
token_scope_insufficient |
Token does not include this capability | no |
token_revoked |
Token id in revoked list | no |
relay_unreachable |
Configured relay tier down | yes, exp backoff |
e2e_session_missing |
No active X3DH session | yes, after key exchange |
e2e_decrypt_failed |
Ciphertext can't be decrypted | no, request rekey |
dht_lookup_failed |
DHT did not find sources in time | yes |
ratchet_out_of_order |
Message too far out of order; sender must rewind | maybe |
10. Versioning and migration
10.1 Mixed-version mesh
A v1.0 node and a v2.0 node may coexist on the same LAN, but:
- A v2.0 node calling a v1.0 node for a Phase 2 capability gets
not_found(v1 didn't register it). - A v1.0 node calling a v2.0 node for a v1 capability works fine (additive contract).
- v2.0 routes around v1.0 nodes for any capability that requires v2 features.
10.2 Migration of an existing community
When the founder upgrades to v2.0:
- New
policy.min_signatures_to_federatefield added with default 3 - New event types unlock; old log still replays cleanly
- Existing nodes prompted to upgrade via
community.policy.updatedevent - After 30 days, federation capabilities won't dispatch to non-upgraded nodes
See MIGRATION_v1_to_v2.md (out of band).
11. Out of scope still (deferred to Phase 3)
- Distributed-tensor inference capabilities (
experimental.distributed_llm.chat) - MoE-style expert routing (lives inside the bus as a learned scorer)
- Federated learning capabilities (
fedlearn.*) - LoRA long-distance beacons (no capability, hardware-only)
- Evidence-layer integration (
evidence.*namespace reserved here, defined in Phase 3) - Conformance test suite as a protocol surface