feat: Phase 4+5 — federation, encryption, graph visualization

Federation module with handshake protocol, trust tiers (trusted/peer/
monitored), X25519 key exchange + ChaCha20-Poly1305 encryption, and
Redis store-and-forward relay (7-day TTL). Federation API: handshake,
approve/reject peers, trust tier management, pending event counts.

D3.js force-directed knowledge graph at /graph with entity-type shapes
and colors, drag/zoom, click-to-inspect, and type filtering.

Root endpoint at / with API info. Two new MCP tools for federation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-13 19:18:38 -04:00
parent de6e1a85b7
commit 9b22e72b5e
9 changed files with 610 additions and 1 deletions

View File

@ -7,3 +7,4 @@ pydantic-settings>=2.0.0
httpx>=0.27.0
pyyaml>=6.0
mcp>=1.0.0
redis>=5.0.0

View File

@ -5,12 +5,15 @@ If running standalone (without koi-net), creates its own app.
"""
import logging
from pathlib import Path
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from spore_node.config import SporeConfig
from spore_node.db.connection import init_pool, close_pool
from spore_node.api.routers import health, holons, governance, claims, intents, commitments
from spore_node.api.routers import health, holons, governance, claims, intents, commitments, federation
log = logging.getLogger(__name__)
@ -23,6 +26,16 @@ def mount_routers(app: FastAPI) -> None:
app.include_router(claims.router)
app.include_router(intents.router)
app.include_router(commitments.router)
app.include_router(federation.router)
# Graph visualization
static_dir = Path(__file__).parent.parent / "static"
if static_dir.exists():
@app.get("/graph")
async def graph_page():
return FileResponse(static_dir / "graph.html")
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
def create_standalone_app(cfg: SporeConfig | None = None) -> FastAPI:

View File

@ -0,0 +1,225 @@
"""Federation endpoints — peer handshake, trust tiers, event relay."""
import json
import uuid
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from spore_node.db.connection import get_pool
from spore_node.federation.crypto import (
generate_keypair, public_key_to_b64, b64_to_public_key, derive_shared_key,
)
router = APIRouter(prefix="/federation", tags=["federation"])
VALID_TRUST_TIERS = ("trusted", "peer", "monitored")
VALID_HANDSHAKE_STATES = ("pending", "proposed", "approved", "rejected")
# Node's own keypair (generated on first handshake)
_node_keypair: tuple[bytes, bytes] | None = None
def _get_keypair() -> tuple[bytes, bytes]:
global _node_keypair
if _node_keypair is None:
_node_keypair = generate_keypair()
return _node_keypair
class HandshakeRequest(BaseModel):
peer_url: str
peer_rid: str = ""
peer_public_key: str = "" # base64-encoded X25519 public key
trust_tier: str = "monitored"
class PeerResponse(BaseModel):
id: str
node_rid: str
node_url: str
trust_tier: str
handshake_status: str
pending_events: int = 0
class HandshakeResponse(BaseModel):
status: str
peer_rid: str
our_public_key: str
message: str
@router.post("/handshake", response_model=HandshakeResponse)
async def initiate_handshake(data: HandshakeRequest):
"""Initiate or accept a federation handshake with a peer node."""
if data.trust_tier not in VALID_TRUST_TIERS:
raise HTTPException(422, f"trust_tier must be one of {VALID_TRUST_TIERS}")
pool = get_pool()
_, our_public = _get_keypair()
peer_rid = data.peer_rid or f"orn:koi-net.node:peer-{uuid.uuid4().hex[:8]}"
# Store peer encryption config
config = {}
if data.peer_public_key:
config["peer_public_key"] = data.peer_public_key
config["our_public_key"] = public_key_to_b64(our_public)
# Derive shared key for this peer
our_private, _ = _get_keypair()
shared = derive_shared_key(our_private, b64_to_public_key(data.peer_public_key))
config["encryption_ready"] = True
try:
await pool.execute(
"""
INSERT INTO koi_peers (node_rid, node_url, trust_tier, handshake_status, config)
VALUES ($1, $2, $3, 'proposed', $4::jsonb)
ON CONFLICT (node_rid) DO UPDATE SET
node_url = EXCLUDED.node_url,
trust_tier = EXCLUDED.trust_tier,
handshake_status = 'proposed',
config = EXCLUDED.config,
updated_at = now()
""",
peer_rid, data.peer_url, data.trust_tier, json.dumps(config),
)
except Exception as e:
raise HTTPException(500, f"Failed to store peer: {e}")
await _log_event(pool, peer_rid, "federation.handshake.proposed",
{"peer_url": data.peer_url, "trust_tier": data.trust_tier})
return HandshakeResponse(
status="proposed",
peer_rid=peer_rid,
our_public_key=public_key_to_b64(our_public),
message=f"Handshake proposed with {data.peer_url}. Awaiting bilateral approval.",
)
@router.patch("/peers/{peer_rid}/approve")
async def approve_peer(peer_rid: str):
"""Approve a pending handshake — transitions to approved."""
pool = get_pool()
row = await pool.fetchrow(
"SELECT handshake_status FROM koi_peers WHERE node_rid = $1", peer_rid
)
if not row:
raise HTTPException(404, "Peer not found")
if row["handshake_status"] == "approved":
return {"status": "already_approved"}
if row["handshake_status"] == "rejected":
raise HTTPException(422, "Peer was rejected. Re-initiate handshake.")
await pool.execute(
"UPDATE koi_peers SET handshake_status = 'approved', updated_at = now() WHERE node_rid = $1",
peer_rid,
)
await _log_event(pool, peer_rid, "federation.handshake.approved", {})
return {"status": "approved", "peer_rid": peer_rid}
@router.patch("/peers/{peer_rid}/reject")
async def reject_peer(peer_rid: str):
"""Reject a pending handshake."""
pool = get_pool()
row = await pool.fetchrow(
"SELECT handshake_status FROM koi_peers WHERE node_rid = $1", peer_rid
)
if not row:
raise HTTPException(404, "Peer not found")
await pool.execute(
"UPDATE koi_peers SET handshake_status = 'rejected', updated_at = now() WHERE node_rid = $1",
peer_rid,
)
await _log_event(pool, peer_rid, "federation.handshake.rejected", {})
return {"status": "rejected", "peer_rid": peer_rid}
@router.patch("/peers/{peer_rid}/trust")
async def update_trust_tier(peer_rid: str, trust_tier: str):
"""Update a peer's trust tier."""
if trust_tier not in VALID_TRUST_TIERS:
raise HTTPException(422, f"trust_tier must be one of {VALID_TRUST_TIERS}")
pool = get_pool()
row = await pool.fetchrow(
"SELECT node_rid FROM koi_peers WHERE node_rid = $1", peer_rid
)
if not row:
raise HTTPException(404, "Peer not found")
await pool.execute(
"UPDATE koi_peers SET trust_tier = $1, updated_at = now() WHERE node_rid = $2",
trust_tier, peer_rid,
)
await _log_event(pool, peer_rid, "federation.trust_tier.changed",
{"trust_tier": trust_tier})
return {"peer_rid": peer_rid, "trust_tier": trust_tier}
@router.get("/peers", response_model=list[PeerResponse])
async def list_peers(trust_tier: str | None = None):
"""List all federation peers."""
pool = get_pool()
if trust_tier:
rows = await pool.fetch(
"SELECT * FROM koi_peers WHERE trust_tier = $1 ORDER BY created_at",
trust_tier,
)
else:
rows = await pool.fetch("SELECT * FROM koi_peers ORDER BY created_at")
return [_peer_dict(r) for r in rows]
@router.get("/peers/{peer_rid}", response_model=PeerResponse)
async def get_peer(peer_rid: str):
"""Get a single peer by RID."""
pool = get_pool()
row = await pool.fetchrow(
"SELECT * FROM koi_peers WHERE node_rid = $1", peer_rid
)
if not row:
raise HTTPException(404, "Peer not found")
return _peer_dict(row)
@router.get("/events/pending")
async def pending_events():
"""Get count of pending relay events per peer."""
pool = get_pool()
peers = await pool.fetch(
"SELECT node_rid, node_url FROM koi_peers WHERE handshake_status = 'approved'"
)
# Import relay lazily to avoid circular deps
from spore_node.federation.relay import FederationRelay
from spore_node.config import SporeConfig
cfg = SporeConfig()
relay = FederationRelay(cfg.redis_url)
result = {}
for peer in peers:
count = await relay.pending_count(peer["node_rid"])
if count > 0:
result[peer["node_rid"]] = count
return result
def _peer_dict(row) -> dict:
d = dict(row)
d["id"] = str(d["id"])
d["pending_events"] = 0
d.pop("config", None)
d.pop("last_seen_at", None)
d.pop("created_at", None)
d.pop("updated_at", None)
return d
async def _log_event(pool, entity_rid: str, event_kind: str, payload: dict):
await pool.execute(
"INSERT INTO events (entity_rid, event_kind, payload) VALUES ($1, $2, $3::jsonb)",
entity_rid, event_kind, json.dumps(payload),
)

View File

@ -6,6 +6,17 @@ from spore_node.db.connection import get_pool
router = APIRouter(tags=["health"])
@router.get("/")
async def root():
"""Root endpoint — redirect to docs."""
return {
"name": "Spore Agent Commons",
"version": "0.1.0",
"docs": "/docs",
"health": "/health",
}
@router.get("/health")
async def health():
"""Node health check with DB connectivity."""

View File

View File

@ -0,0 +1,55 @@
"""E2E encryption for federation payloads.
X25519 key exchange + ChaCha20-Poly1305 for symmetric encryption.
Uses the `cryptography` library (already a koi-net dependency).
"""
import base64
import os
from cryptography.hazmat.primitives.asymmetric.x25519 import (
X25519PrivateKey,
X25519PublicKey,
)
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
def generate_keypair() -> tuple[bytes, bytes]:
"""Generate X25519 keypair. Returns (private_key_bytes, public_key_bytes)."""
private_key = X25519PrivateKey.generate()
public_key = private_key.public_key()
return (
private_key.private_bytes_raw(),
public_key.public_bytes_raw(),
)
def derive_shared_key(our_private: bytes, their_public: bytes) -> bytes:
"""Derive shared secret from X25519 key exchange."""
private_key = X25519PrivateKey.from_private_bytes(our_private)
public_key = X25519PublicKey.from_public_bytes(their_public)
return private_key.exchange(public_key)
def encrypt(shared_key: bytes, plaintext: bytes) -> bytes:
"""Encrypt with ChaCha20-Poly1305. Returns nonce + ciphertext."""
aead = ChaCha20Poly1305(shared_key)
nonce = os.urandom(12)
ciphertext = aead.encrypt(nonce, plaintext, None)
return nonce + ciphertext
def decrypt(shared_key: bytes, data: bytes) -> bytes:
"""Decrypt ChaCha20-Poly1305. Expects nonce (12 bytes) + ciphertext."""
aead = ChaCha20Poly1305(shared_key)
nonce = data[:12]
ciphertext = data[12:]
return aead.decrypt(nonce, ciphertext, None)
def public_key_to_b64(key_bytes: bytes) -> str:
return base64.b64encode(key_bytes).decode()
def b64_to_public_key(b64: str) -> bytes:
return base64.b64decode(b64)

View File

@ -0,0 +1,94 @@
"""Store-and-forward relay for offline federation peers.
Uses Redis sorted sets with TTL. Events queued per peer, flushed on schedule.
"""
import json
import time
import logging
from typing import Any
import httpx
log = logging.getLogger(__name__)
RELAY_PREFIX = "spore:relay:"
RELAY_TTL_SECONDS = 7 * 24 * 3600 # 7 days
class FederationRelay:
def __init__(self, redis_url: str):
self._redis_url = redis_url
self._redis = None
async def _get_redis(self):
if self._redis is None:
import redis.asyncio as aioredis
self._redis = aioredis.from_url(self._redis_url)
return self._redis
async def enqueue(self, peer_rid: str, event: dict) -> None:
"""Queue an event for a peer."""
r = await self._get_redis()
key = f"{RELAY_PREFIX}{peer_rid}"
score = time.time()
await r.zadd(key, {json.dumps(event): score})
# Set TTL on the key
await r.expire(key, RELAY_TTL_SECONDS)
async def flush_peer(self, peer_rid: str, peer_url: str) -> int:
"""Attempt to flush queued events to a peer. Returns count flushed."""
r = await self._get_redis()
key = f"{RELAY_PREFIX}{peer_rid}"
# Get all queued events
events = await r.zrangebyscore(key, "-inf", "+inf")
if not events:
return 0
flushed = 0
async with httpx.AsyncClient(timeout=30) as client:
for raw in events:
event = json.loads(raw)
try:
resp = await client.post(
f"{peer_url}/koi-net/events/broadcast",
json=event,
)
resp.raise_for_status()
await r.zrem(key, raw)
flushed += 1
except Exception as e:
log.warning(f"Failed to flush to {peer_rid}: {e}")
break # Stop on first failure
return flushed
async def flush_all(self, peers: list[dict]) -> dict[str, int]:
"""Flush events to all peers. Returns {peer_rid: count}."""
results = {}
for peer in peers:
if peer.get("handshake_status") != "approved":
continue
count = await self.flush_peer(peer["node_rid"], peer["node_url"])
if count > 0:
results[peer["node_rid"]] = count
return results
async def pending_count(self, peer_rid: str) -> int:
"""Get count of pending events for a peer."""
r = await self._get_redis()
key = f"{RELAY_PREFIX}{peer_rid}"
return await r.zcard(key)
async def prune_expired(self) -> int:
"""Remove events older than TTL. Returns count removed."""
r = await self._get_redis()
cutoff = time.time() - RELAY_TTL_SECONDS
total = 0
async for key in r.scan_iter(f"{RELAY_PREFIX}*"):
removed = await r.zremrangebyscore(key, "-inf", cutoff)
total += removed
return total

View File

@ -366,5 +366,36 @@ def search_knowledge(query: str, entity_type: str = "",
return json.dumps(results, indent=2)
# --- Federation tools ---
@server.tool()
def list_federation_peers(trust_tier: str = "") -> str:
"""List all federation peers with their trust tiers and handshake status.
Args:
trust_tier: Filter by tier (trusted, peer, monitored). Empty = all.
"""
params = f"?trust_tier={trust_tier}" if trust_tier else ""
result = _api("GET", f"/federation/peers{params}")
return json.dumps(result, indent=2)
@server.tool()
def initiate_federation_handshake(peer_url: str, peer_rid: str = "",
trust_tier: str = "monitored") -> str:
"""Initiate a federation handshake with a peer node.
Args:
peer_url: URL of the peer node (e.g. 'https://bkc.example.com')
peer_rid: RID of the peer (optional, auto-generated if empty)
trust_tier: Initial trust tier (trusted, peer, monitored)
"""
result = _api("POST", "/federation/handshake", {
"peer_url": peer_url, "peer_rid": peer_rid,
"trust_tier": trust_tier,
})
return json.dumps(result, indent=2)
if __name__ == "__main__":
server.run(transport="stdio")

View File

@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Spore Agent Commons — Knowledge Graph</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0d1117; color: #c9d1d9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; overflow: hidden; }
#controls { position: fixed; top: 12px; left: 12px; z-index: 10; display: flex; gap: 8px; flex-wrap: wrap; }
.btn { background: #21262d; border: 1px solid #30363d; color: #c9d1d9; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; }
.btn:hover { background: #30363d; }
.btn.active { background: #1f6feb; border-color: #388bfd; }
#info { position: fixed; bottom: 12px; left: 12px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 12px 16px; max-width: 360px; font-size: 13px; z-index: 10; }
#info h3 { color: #58a6ff; margin-bottom: 6px; }
#info .field { margin: 3px 0; }
#info .label { color: #8b949e; }
#legend { position: fixed; top: 12px; right: 12px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 10px 14px; font-size: 12px; z-index: 10; }
.legend-item { display: flex; align-items: center; gap: 6px; margin: 4px 0; }
.legend-dot { width: 12px; height: 12px; border-radius: 50%; }
svg { width: 100vw; height: 100vh; }
.link { stroke-opacity: 0.4; }
.node-label { font-size: 11px; fill: #8b949e; pointer-events: none; }
</style>
</head>
<body>
<div id="controls">
<button class="btn active" data-filter="all">All</button>
<button class="btn" data-filter="holon">Holons</button>
<button class="btn" data-filter="claim">Claims</button>
<button class="btn" data-filter="intent">Intents</button>
<button class="btn" data-filter="commitment">Commitments</button>
<button class="btn" data-filter="governance">Governance</button>
</div>
<div id="legend">
<div class="legend-item"><div class="legend-dot" style="background:#58a6ff"></div> Holon</div>
<div class="legend-item"><div class="legend-dot" style="background:#f0883e"></div> Claim</div>
<div class="legend-item"><div class="legend-dot" style="background:#3fb950"></div> Evidence</div>
<div class="legend-item"><div class="legend-dot" style="background:#d2a8ff"></div> Intent</div>
<div class="legend-item"><div class="legend-dot" style="background:#f778ba"></div> Commitment</div>
<div class="legend-item"><div class="legend-dot" style="background:#79c0ff"></div> Governance</div>
</div>
<div id="info"><h3>Spore Agent Commons</h3><p>Click a node to inspect</p></div>
<svg></svg>
<script>
const COLORS = {
holon: '#58a6ff', claim: '#f0883e', evidence: '#3fb950',
intent: '#d2a8ff', commitment: '#f778ba', governance: '#79c0ff',
attestation: '#ffa657', peer: '#56d364'
};
const SHAPES = {
holon: d3.symbolCircle, claim: d3.symbolSquare, evidence: d3.symbolTriangle,
intent: d3.symbolDiamond, commitment: d3.symbolStar, governance: d3.symbolCross,
};
const RADIUS = { holon: 10, claim: 8, evidence: 6, intent: 9, commitment: 10, governance: 8 };
let allNodes = [], allLinks = [], activeFilter = 'all';
async function fetchData() {
const [holons, claims, intents, commitments, dag] = await Promise.all([
fetch('/holons').then(r => r.json()),
fetch('/claims').then(r => r.json()),
fetch('/intents').then(r => r.json()),
fetch('/commitments').then(r => r.json()),
fetch('/governance/dag').then(r => r.json()),
]);
const nodes = [], links = [];
holons.forEach(h => nodes.push({ id: h.rid, label: h.name, kind: 'holon', data: h }));
claims.forEach(c => {
nodes.push({ id: c.rid, label: c.content.slice(0, 40), kind: 'claim', data: c });
if (c.proposer_rid) links.push({ source: c.proposer_rid, target: c.rid, type: 'proposed' });
});
intents.forEach(i => {
nodes.push({ id: i.rid, label: i.title, kind: 'intent', data: i });
if (i.publisher_rid) links.push({ source: i.publisher_rid, target: i.rid, type: 'published' });
});
commitments.forEach(c => {
nodes.push({ id: c.rid, label: c.title, kind: 'commitment', data: c });
if (c.proposer_rid) links.push({ source: c.proposer_rid, target: c.rid, type: 'proposed' });
if (c.acceptor_rid) links.push({ source: c.acceptor_rid, target: c.rid, type: 'accepted' });
});
dag.nodes.forEach(d => {
nodes.push({ id: `gov:${d.doc_id}`, label: d.title || d.doc_id, kind: 'governance', data: d });
d.parents.forEach(p => links.push({ source: `gov:${p}`, target: `gov:${d.doc_id}`, type: 'depends_on' }));
});
// Filter out links with missing nodes
const nodeIds = new Set(nodes.map(n => n.id));
const validLinks = links.filter(l => nodeIds.has(l.source) && nodeIds.has(l.target));
return { nodes, links: validLinks };
}
function render(nodes, links) {
const svg = d3.select('svg');
svg.selectAll('*').remove();
const w = window.innerWidth, h = window.innerHeight;
const g = svg.append('g');
svg.call(d3.zoom().scaleExtent([0.2, 5]).on('zoom', e => g.attr('transform', e.transform)));
const sim = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(80))
.force('charge', d3.forceManyBody().strength(-200))
.force('center', d3.forceCenter(w / 2, h / 2))
.force('collision', d3.forceCollide().radius(20));
const link = g.selectAll('.link').data(links).enter().append('line')
.attr('class', 'link')
.attr('stroke', '#30363d')
.attr('stroke-width', 1.5);
const node = g.selectAll('.node').data(nodes).enter().append('g')
.attr('class', 'node')
.call(d3.drag().on('start', dragStart).on('drag', dragging).on('end', dragEnd));
node.append('path')
.attr('d', d => d3.symbol().type(SHAPES[d.kind] || d3.symbolCircle).size((RADIUS[d.kind] || 8) ** 2 * 3)())
.attr('fill', d => COLORS[d.kind] || '#8b949e')
.attr('stroke', '#0d1117')
.attr('stroke-width', 1.5)
.style('cursor', 'pointer');
node.append('text')
.attr('class', 'node-label')
.attr('dx', 14).attr('dy', 4)
.text(d => d.label.length > 30 ? d.label.slice(0, 30) + '…' : d.label);
node.on('click', (e, d) => {
const info = document.getElementById('info');
info.innerHTML = `<h3>${d.label}</h3>
<div class="field"><span class="label">Kind:</span> ${d.kind}</div>
<div class="field"><span class="label">RID:</span> ${d.id}</div>
${d.data.status ? `<div class="field"><span class="label">Status:</span> ${d.data.status}</div>` : ''}
${d.data.state ? `<div class="field"><span class="label">State:</span> ${d.data.state}</div>` : ''}
${d.data.holon_type ? `<div class="field"><span class="label">Type:</span> ${d.data.holon_type}</div>` : ''}
${d.data.intent_type ? `<div class="field"><span class="label">Intent:</span> ${d.data.intent_type}</div>` : ''}
${d.data.confidence !== undefined ? `<div class="field"><span class="label">Confidence:</span> ${d.data.confidence}</div>` : ''}`;
});
sim.on('tick', () => {
link.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
.attr('x2', d => d.target.x).attr('y2', d => d.target.y);
node.attr('transform', d => `translate(${d.x},${d.y})`);
});
function dragStart(e, d) { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }
function dragging(e, d) { d.fx = e.x; d.fy = e.y; }
function dragEnd(e, d) { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }
}
function applyFilter(filter) {
activeFilter = filter;
document.querySelectorAll('.btn').forEach(b => b.classList.toggle('active', b.dataset.filter === filter));
const nodes = filter === 'all' ? allNodes : allNodes.filter(n => n.kind === filter);
const nodeIds = new Set(nodes.map(n => n.id));
const links = allLinks.filter(l => {
const sId = typeof l.source === 'object' ? l.source.id : l.source;
const tId = typeof l.target === 'object' ? l.target.id : l.target;
return nodeIds.has(sId) && nodeIds.has(tId);
});
// Deep clone to avoid D3 mutation issues
render(nodes.map(n => ({...n})), links.map(l => ({source: typeof l.source === 'object' ? l.source.id : l.source, target: typeof l.target === 'object' ? l.target.id : l.target, type: l.type})));
}
document.querySelectorAll('.btn').forEach(b => b.addEventListener('click', () => applyFilter(b.dataset.filter)));
fetchData().then(({ nodes, links }) => {
allNodes = nodes;
allLinks = links;
render(nodes, links);
});
</script>
</body>
</html>