Watch
1
0
Fork
You've already forked souveraine
0

feat(federation): Phase 8 — hearth & limb role model

FederationRole (hearth/limb) in config; bridge carries role in
device_announce; DeviceRegistry tracks role per peer with a split-brain
guard (two hearths → ERROR!). Role defaults to hearth: a standalone
machine is its own home.
This commit is contained in:
Fimeg 2026-05-15 16:01:21 -04:00
commit 3629fa8b78
5 changed files with 77 additions and 6 deletions

View file

@ -662,12 +662,41 @@ fn default_stale_timeout_secs() -> u64 { 90 }
// ── Federation ──
/// A machine's role in the federation.
///
/// - `Hearth` — where the agent lives. Full engine always up, the autonomous
/// rhythm runs here, the memfs HEAD is authoritative. One per agent.
/// - `Limb` — a place she can reach to. At rest it's the lite listener; an
/// authorized summon wakes the full engine, it acts, reports up, and rests.
/// A limb does not run the general autonomous rhythm — that keeps one
/// heartbeat at the hearth and no split-brain across machines.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum FederationRole {
#[default]
Hearth,
Limb,
}
impl FederationRole {
pub fn as_str(&self) -> &'static str {
match self {
Self::Hearth => "hearth",
Self::Limb => "limb",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FederationConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub instance_label: Option<String>,
/// This machine's role — hearth (the agent's home) or limb (a place she
/// reaches to). Defaults to hearth: a standalone machine is its own home.
#[serde(default)]
pub role: FederationRole,
/// Peers this instance federates with. Each peer connection is a signed
/// WS stream to the peer's federation endpoint.
#[serde(default)]
@ -690,6 +719,7 @@ impl Default for FederationConfig {
Self {
enabled: false,
instance_label: None,
role: FederationRole::Hearth,
peers: Vec::new(),
authorized_summoners: Vec::new(),
auto_wake: false,

View file

@ -1,3 +1,4 @@
use crate::core::config::FederationRole;
use crate::core::nervous::SensorEvent;
use chrono::{DateTime, Utc};
use dashmap::DashMap;
@ -5,6 +6,10 @@ use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
fn default_role() -> String {
"hearth".to_string()
}
/// A peer device known to this federation. Updated on every `device_announce`
/// and `device_leave` event from the peer's bridge.
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -15,6 +20,9 @@ pub struct PeerEntry {
pub label: Option<String>,
/// The peer's federation endpoint (ws://host:port).
pub url: String,
/// The peer's federation role — "hearth" or "limb".
#[serde(default = "default_role")]
pub role: String,
/// First time we saw this peer's announce.
pub first_seen: DateTime<Utc>,
/// Most recent announce.
@ -30,12 +38,14 @@ pub struct DeviceRegistry {
known_peers_path: PathBuf,
/// This instance's own seed_id (pubkey hex) — filters self-announcements.
local_seed_id: String,
/// This machine's role — used to detect a hearth/hearth split-brain.
local_role: FederationRole,
}
impl DeviceRegistry {
/// `base_dir` = `~/.souveraine/`. The registry writes to
/// `{base_dir}/federation/known_peers.json`.
pub fn new(base_dir: PathBuf, local_seed_id: String) -> Self {
pub fn new(base_dir: PathBuf, local_seed_id: String, local_role: FederationRole) -> Self {
let fed_dir = base_dir.join("federation");
let known_peers_path = fed_dir.join("known_peers.json");
let peers = DashMap::new();
@ -53,6 +63,7 @@ impl DeviceRegistry {
peers,
known_peers_path,
local_seed_id,
local_role,
}
}
@ -81,6 +92,24 @@ impl DeviceRegistry {
.and_then(|p| p.get("label"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let role = event
.payload
.as_ref()
.and_then(|p| p.get("role"))
.and_then(|v| v.as_str())
.unwrap_or("hearth")
.to_string();
// Split-brain guard: two hearths for one agent diverge memory.
// We refuse to auto-resolve — surface it loudly for the human.
if role == "hearth" && self.local_role == FederationRole::Hearth {
tracing::error!(
peer = %seed_id,
"federation: HEARTH CONFLICT — this machine and {} both \
claim hearth. Only one machine should be the hearth; \
set [federation].role = \"limb\" on one of them.",
seed_id,
);
}
let now = Utc::now();
// Preserve the original first_seen across re-announces.
let first_seen = self.peers.get(&seed_id)
@ -93,6 +122,7 @@ impl DeviceRegistry {
seed_id: seed_id.clone(),
label,
url,
role,
first_seen,
last_seen: now,
alive: true,

View file

@ -6,7 +6,7 @@ use std::time::Duration;
use futures::SinkExt;
use tokio_tungstenite::tungstenite::Message;
use crate::core::config::PeerConfig;
use crate::core::config::{FederationRole, PeerConfig};
use crate::core::identity::SeedId;
use crate::core::nervous::{EventBus, SensorEvent};
@ -20,14 +20,16 @@ use super::types::SignedEvent;
pub struct FederationBridge {
event_bus: EventBus,
seed: Arc<SeedId>,
role: FederationRole,
peers: Vec<PeerConfig>,
}
impl FederationBridge {
pub fn new(event_bus: EventBus, seed: Arc<SeedId>) -> Self {
pub fn new(event_bus: EventBus, seed: Arc<SeedId>, role: FederationRole) -> Self {
Self {
event_bus,
seed,
role,
peers: Vec::new(),
}
}
@ -45,6 +47,7 @@ impl FederationBridge {
peer,
self.event_bus.clone(),
self.seed.clone(),
self.role,
));
}
}
@ -52,7 +55,12 @@ impl FederationBridge {
/// Connect to one peer and forward signed events, reconnecting with
/// exponential backoff whenever the link drops.
async fn peer_outbound_task(peer: PeerConfig, event_bus: EventBus, seed: Arc<SeedId>) {
async fn peer_outbound_task(
peer: PeerConfig,
event_bus: EventBus,
seed: Arc<SeedId>,
role: FederationRole,
) {
let endpoint = federation_endpoint(&peer.url);
let mut retry: u32 = 0;
@ -73,6 +81,7 @@ async fn peer_outbound_task(peer: PeerConfig, event_bus: EventBus, seed: Arc<See
"federation_url": endpoint,
"label": None::<String>,
"pubkey": seed.public_key_hex(),
"role": role.as_str(),
})),
seed_id: None,
reply_to: None,

View file

@ -74,7 +74,7 @@ impl LiteListener {
// Outbound federation bridge — so this instance can also reach peers.
if !config.peers.is_empty() {
let mut bridge = FederationBridge::new(event_bus.clone(), Arc::new(seed));
let mut bridge = FederationBridge::new(event_bus.clone(), Arc::new(seed), config.role);
for peer in &config.peers {
bridge.add_peer(peer.clone());
}

View file

@ -220,8 +220,9 @@ impl SouveraineServer {
Err(_) => None,
};
let sb_for_device_reg = souveraine_base.clone();
let local_role = config.federation.role;
let device_registry = local_seed_id.clone().map(|seed_id| {
let reg = Arc::new(DeviceRegistry::new(sb_for_device_reg, seed_id));
let reg = Arc::new(DeviceRegistry::new(sb_for_device_reg, seed_id, local_role));
// Subscribe the registry to the event bus for live updates.
let reg_clone = reg.clone();
let mut rx = event_bus.subscribe();
@ -311,6 +312,7 @@ impl SouveraineServer {
let mut bridge = federation::FederationBridge::new(
self.event_bus.clone(),
Arc::new(seed),
fed.role,
);
for peer in fed.peers {
bridge.add_peer(peer);