Skip to main content

clawft_kernel/
node_facts_exchange.rs

1//! Node facts over the mesh (ADR-099 sections 2 and 8, card mesh-placement-03).
2//!
3//! [`FactsExchange`] is the node-local side of facts distribution:
4//!
5//! - **Publish.** The local node signs its [`NodeFacts`] and sends the
6//!   signed base to every peer ([`FactsExchange::publish`]). When only live
7//!   state changed (capability states, free memory) it sends a small signed
8//!   [`FactsDelta`] instead ([`FactsExchange::update_live`]); a changed
9//!   capability set, or a base nearing its TTL, produces a new base. A peer
10//!   that joins, or that reports a missing base, is sent the current base
11//!   and latest delta.
12//! - **Receive.** Facts arrive as `mesh.node_facts` control messages
13//!   ([`FACTS_TOPIC`]) and are handled through [`PeerControlSink`] with the
14//!   connection's authenticated identity. Nothing is trusted on arrival:
15//!   the signature, the key-to-node-id binding, the TTL window and the
16//!   `seq` ordering are all checked by [`NodeFactsCache`], and a peer can
17//!   only speak for itself (`node_id` must be the sender).
18//! - **Budget.** Each connection has a frame and byte budget, charged on the
19//!   whole frame and spent before anything is parsed or verified; one
20//!   connection holds at most [`MAX_DISCOVERED_PER_CONN`] `Discovered`
21//!   entries and the cache holds at most 512 in all (the heaviest connection
22//!   loses its oldest entry first). A connection that reconnects gets a fresh
23//!   id and a fresh quota, so a patient attacker can still churn the
24//!   `Discovered` class; `Paired` and `Pinned` entries are never touched.
25//! - **Trust.** The receiver assigns the [`TrustTier`]. A peer whose node id
26//!   admission verified (ADR-103 A10) gets [`FactsTrustPolicy::verified_tier`];
27//!   any other connection (including every peer under `observe`, where none
28//!   is marked verified) gets the lowest tier. A node's tier is never
29//!   lowered by a later frame, and the local node's facts can never be
30//!   replaced from the wire.
31//! - **Provenance.** The receiver did not probe or measure remote data, so
32//!   the cached copy caps every capability's provenance: `probed` for a
33//!   verified peer, `claimed` otherwise. `measured` is never held for a
34//!   remote node. The signed envelope is kept as received.
35
36use std::sync::{Arc, Mutex};
37
38use clawft_types::placement::{
39    CapabilityState, FactsDelta, NodeFacts, Provenance, StateChange, TrustTier,
40};
41use ed25519_dalek::SigningKey;
42use serde::{Deserialize, Serialize};
43
44use crate::cluster::ClusterMembership;
45use crate::ipc::{KernelMessage, MessagePayload, MessageTarget};
46use crate::mesh_delivery::PeerCtx;
47use crate::mesh_discovery::MeshPeerEvent;
48pub use crate::mesh_runtime::{FACTS_TOPIC, PeerControlSink};
49use crate::mesh_runtime::MeshRuntime;
50use crate::node_facts::{CacheError, InsertOutcome};
51use crate::node_facts_advert::{
52    NodeFactsAdvertError, SignedFactsDelta, SignedNodeFacts, sign_facts_delta, sign_node_facts,
53    verify_facts_delta,
54};
55use crate::node_registry::node_id_from_pubkey;
56
57/// Most `Discovered` entries one connection may hold: an unverified
58/// connection can claim any source id, so it cannot fill the cache.
59pub const MAX_DISCOVERED_PER_CONN: usize = 4;
60/// Frames per second one connection may send on the facts topic (burst 2x).
61pub const FRAMES_PER_SEC: f64 = 20.0;
62/// Payload bytes per second one connection may send (burst 4x).
63pub const BYTES_PER_SEC: f64 = 256.0 * 1024.0;
64/// Connections whose budgets are remembered at once.
65const MAX_TRACKED_CONNS: usize = 1024;
66
67/// Token buckets for one connection.
68struct Budget {
69    frames: f64,
70    bytes: f64,
71    last: std::time::Instant,
72}
73
74/// A new base is signed once the current one has used this share of its TTL
75/// (deltas do not extend a base's lifetime).
76const REBASE_NUMERATOR: u64 = 4;
77const REBASE_DENOMINATOR: u64 = 5;
78
79/// Wire body of a `mesh.node_facts` message.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(tag = "op", rename_all = "snake_case")]
82pub enum FactsWire {
83    /// Signed base facts.
84    Facts {
85        /// The signed block.
86        signed: SignedNodeFacts,
87    },
88    /// Signed live-state delta.
89    Delta {
90        /// The signed delta.
91        signed: SignedFactsDelta,
92    },
93    /// "Send me your facts": the sender holds no usable base for you.
94    Request,
95}
96
97impl FactsWire {
98    fn to_value(&self) -> Option<serde_json::Value> {
99        serde_json::to_value(self).ok()
100    }
101}
102
103/// What tier and provenance a peer's facts earn.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub struct FactsTrustPolicy {
106    /// Tier for a peer whose node id admission verified. `Paired` here is the
107    /// cache's class (protected from `Discovered` churn); it is not an
108    /// operator pairing and placement never reads it. `weaver cluster nodes
109    /// --facts` shows it as "mesh-verified" for that reason.
110    pub verified_tier: TrustTier,
111    /// Tier for every other connection.
112    pub unverified_tier: TrustTier,
113    /// Provenance ceiling for a verified peer's capabilities.
114    pub verified_cap: Provenance,
115    /// Provenance ceiling for every other connection's capabilities.
116    pub unverified_cap: Provenance,
117}
118
119impl Default for FactsTrustPolicy {
120    fn default() -> Self {
121        Self {
122            verified_tier: TrustTier::Paired,
123            unverified_tier: TrustTier::Discovered,
124            verified_cap: Provenance::Probed,
125            unverified_cap: Provenance::Claimed,
126        }
127    }
128}
129
130/// What receiving one facts message did.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum IngestOutcome {
133    /// Facts cached under `tier`.
134    Accepted {
135        /// Node the facts describe.
136        node_id: String,
137        /// Tier the receiver assigned.
138        tier: TrustTier,
139        /// Whether a node was added, replaced or unchanged.
140        outcome: InsertOutcome,
141    },
142    /// Delta applied to the cached base.
143    DeltaApplied {
144        /// Node the delta describes.
145        node_id: String,
146    },
147    /// The peer asked for our facts; they were queued as replies.
148    Requested,
149}
150
151/// Why a facts message was refused.
152#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
153pub enum IngestError {
154    /// The message is not a well-formed [`FactsWire`].
155    #[error("malformed facts message: {0}")]
156    Malformed(String),
157    /// The facts name a node other than the sender.
158    #[error("facts for {subject} sent by {sender}")]
159    NotSender {
160        /// Node the facts describe.
161        subject: String,
162        /// Connection identity.
163        sender: String,
164    },
165    /// A peer tried to replace this node's own facts.
166    #[error("refusing remote facts for the local node")]
167    LocalNode,
168    /// One connection holds its maximum of `Discovered` entries.
169    #[error("connection {conn} already supplied {limit} unverified nodes")]
170    Quota {
171        /// Connection id.
172        conn: u64,
173        /// The per-connection limit.
174        limit: usize,
175    },
176    /// Signature, binding, TTL, ordering or delta check failed.
177    #[error(transparent)]
178    Cache(#[from] CacheError),
179}
180
181/// The latest facts this node published.
182struct Local {
183    base: Option<SignedNodeFacts>,
184    facts: Option<NodeFacts>,
185    delta: Option<SignedFactsDelta>,
186    delta_seq: u64,
187    last_seq: u64,
188}
189
190/// What [`FactsExchange::update_live`] sent.
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub enum Published {
193    /// A new signed base with this `seq`.
194    Base(u64),
195    /// A delta with this `seq` carrying this many state changes.
196    Delta {
197        /// Delta sequence.
198        seq: u64,
199        /// Capability state changes in it.
200        changes: usize,
201    },
202    /// Nothing changed; nothing sent.
203    Nothing,
204}
205
206/// Node-local facts distribution.
207pub struct FactsExchange {
208    node_id: String,
209    key: SigningKey,
210    membership: Arc<ClusterMembership>,
211    runtime: Arc<MeshRuntime>,
212    policy: FactsTrustPolicy,
213    local: Mutex<Local>,
214    budgets: dashmap::DashMap<u64, Budget>,
215}
216
217impl FactsExchange {
218    /// Exchange for the node owning `key`, sending over `runtime` and
219    /// caching in `membership`.
220    pub fn new(
221        key: SigningKey,
222        membership: Arc<ClusterMembership>,
223        runtime: Arc<MeshRuntime>,
224        policy: FactsTrustPolicy,
225    ) -> Arc<Self> {
226        let node_id = node_id_from_pubkey(&key.verifying_key().to_bytes());
227        Arc::new(Self {
228            node_id,
229            key,
230            membership,
231            runtime,
232            policy,
233            local: Mutex::new(Local {
234                base: None,
235                facts: None,
236                delta: None,
237                delta_seq: 0,
238                last_seq: 0,
239            }),
240            budgets: dashmap::DashMap::new(),
241        })
242    }
243
244    /// Install as the runtime's facts sink and announce the current facts to
245    /// every peer that joins or recovers. Call once.
246    pub fn start(self: &Arc<Self>) {
247        self.runtime.set_control_sink(FACTS_TOPIC, self.clone());
248        let me = self.clone();
249        let mut events = self.runtime.subscribe_peer_events();
250        tokio::spawn(async move {
251            loop {
252                match events.recv().await {
253                    Ok(MeshPeerEvent::Joined { node_id, .. })
254                    | Ok(MeshPeerEvent::Recovered { node_id, .. }) => {
255                        me.send_current(&node_id).await;
256                    }
257                    // The connection is gone: the mesh-derived tier goes with it
258                    // (an admission revocation closes the connection).
259                    Ok(MeshPeerEvent::Left { node_id }) => {
260                        me.on_peer_gone(&node_id);
261                    }
262                    Ok(_) => {}
263                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
264                    Err(_) => break,
265                }
266            }
267        });
268    }
269
270    /// A peer's connection ended or its admission was revoked: lower its
271    /// mesh-derived tier to `Discovered`. An operator-set tier stays.
272    pub fn on_peer_gone(&self, node_id: &str) -> bool {
273        self.membership.facts().demote_mesh_tier(node_id)
274    }
275
276    /// Charge one frame of `bytes` to connection `conn`. False = over budget.
277    fn allow(&self, conn: u64, bytes: usize) -> bool {
278        if self.budgets.len() >= MAX_TRACKED_CONNS && !self.budgets.contains_key(&conn) {
279            let oldest = self.budgets.iter().min_by_key(|b| b.last).map(|b| *b.key());
280            if let Some(k) = oldest {
281                self.budgets.remove(&k);
282            }
283        }
284        let now = std::time::Instant::now();
285        let mut b = self.budgets.entry(conn).or_insert(Budget {
286            frames: 2.0 * FRAMES_PER_SEC,
287            bytes: 4.0 * BYTES_PER_SEC,
288            last: now,
289        });
290        let dt = now.duration_since(b.last).as_secs_f64();
291        b.last = now;
292        b.frames = (b.frames + dt * FRAMES_PER_SEC).min(2.0 * FRAMES_PER_SEC);
293        b.bytes = (b.bytes + dt * BYTES_PER_SEC).min(4.0 * BYTES_PER_SEC);
294        if b.frames < 1.0 || b.bytes < bytes as f64 {
295            return false;
296        }
297        b.frames -= 1.0;
298        b.bytes -= bytes as f64;
299        true
300    }
301
302    /// This node's id.
303    pub fn node_id(&self) -> &str {
304        &self.node_id
305    }
306
307    /// The membership whose cache this exchange fills.
308    pub fn membership(&self) -> &Arc<ClusterMembership> {
309        &self.membership
310    }
311
312    fn now() -> u64 {
313        std::time::SystemTime::now()
314            .duration_since(std::time::UNIX_EPOCH)
315            .map(|d| d.as_secs())
316            .unwrap_or(0)
317    }
318
319    fn lock(&self) -> std::sync::MutexGuard<'_, Local> {
320        self.local.lock().unwrap_or_else(|p| p.into_inner())
321    }
322
323    /// Sign `facts` as the new base (its `seq` is replaced by the next one),
324    /// cache them as the local node (`pinned`) and send them to every peer.
325    pub async fn publish(&self, facts: NodeFacts, now: u64) -> Result<u64, NodeFactsAdvertError> {
326        let (seq, signed) = self.install_base(facts, now)?;
327        self.broadcast(FactsWire::Facts { signed }).await;
328        Ok(seq)
329    }
330
331    fn install_base(
332        &self,
333        mut facts: NodeFacts,
334        now: u64,
335    ) -> Result<(u64, SignedNodeFacts), NodeFactsAdvertError> {
336        let mut local = self.lock();
337        let seq = (local.last_seq + 1).max(now);
338        facts.node_id = self.node_id.clone();
339        facts.issued_at = now;
340        facts.seq = seq;
341        let signed = sign_node_facts(&facts, &self.key)?;
342        self.membership
343            .facts()
344            .insert(signed.clone(), TrustTier::Pinned, now)
345            .map_err(|e| NodeFactsAdvertError::Malformed(e.to_string()))?;
346        local.last_seq = seq;
347        local.base = Some(signed.clone());
348        local.facts = Some(facts);
349        local.delta = None;
350        local.delta_seq = 0;
351        Ok((seq, signed))
352    }
353
354    /// Publish a fresh probe of the local node: a delta when only live state
355    /// (capability states, free memory) changed since the last base, a new
356    /// base when the capability set changed or the base is nearing its TTL,
357    /// nothing when nothing changed.
358    pub async fn update_live(
359        &self,
360        probed: NodeFacts,
361        now: u64,
362    ) -> Result<Published, NodeFactsAdvertError> {
363        enum Step {
364            Base(NodeFacts),
365            Delta(SignedFactsDelta, usize),
366            Nothing,
367        }
368        let step = {
369            let mut local = self.lock();
370            match local.facts.clone() {
371                Some(base)
372                    if now < base.issued_at + base.ttl_secs * REBASE_NUMERATOR / REBASE_DENOMINATOR
373                        && same_shape(&base, &probed) =>
374                {
375                    let delta = live_delta(&base, &probed, local.delta_seq + 1, now);
376                    if delta.changes.is_empty() && delta.mem_free.is_none() {
377                        Step::Nothing
378                    } else {
379                        let n = delta.changes.len();
380                        let signed = sign_facts_delta(&delta, &self.key)?;
381                        local.delta_seq = delta.seq;
382                        local.delta = Some(signed.clone());
383                        // Track the new live state so the next diff is against it.
384                        if let Some(f) = local.facts.as_mut() {
385                            f.apply_delta(&delta).map_err(NodeFactsAdvertError::Facts)?;
386                        }
387                        let _ = self.membership.facts().apply_delta(&signed, now);
388                        Step::Delta(signed, n)
389                    }
390                }
391                _ => Step::Base(probed),
392            }
393        };
394        match step {
395            Step::Nothing => Ok(Published::Nothing),
396            Step::Base(f) => self.publish(f, now).await.map(Published::Base),
397            Step::Delta(signed, changes) => {
398                let seq = {
399                    let (d, _) = verify_facts_delta(&signed, now)?;
400                    d.seq
401                };
402                self.broadcast(FactsWire::Delta { signed }).await;
403                Ok(Published::Delta { seq, changes })
404            }
405        }
406    }
407
408    async fn broadcast(&self, wire: FactsWire) {
409        for peer in self.runtime.peer_ids() {
410            self.send_wire(&peer, &wire).await;
411        }
412    }
413
414    async fn send_wire(&self, peer: &str, wire: &FactsWire) {
415        let Some(value) = wire.to_value() else { return };
416        let msg = KernelMessage::new(
417            0,
418            MessageTarget::Topic(FACTS_TOPIC.to_string()),
419            MessagePayload::Json(value),
420        );
421        if let Err(e) = self.runtime.route_to_remote(peer, msg).await {
422            tracing::debug!(peer, error = %e, "node facts send failed");
423        }
424    }
425
426    fn current(&self) -> Vec<FactsWire> {
427        let local = self.lock();
428        let mut out = Vec::new();
429        if let Some(signed) = local.base.clone() {
430            out.push(FactsWire::Facts { signed });
431        }
432        if let Some(signed) = local.delta.clone() {
433            out.push(FactsWire::Delta { signed });
434        }
435        out
436    }
437
438    async fn send_current(&self, peer: &str) {
439        for wire in self.current() {
440            self.send_wire(peer, &wire).await;
441        }
442    }
443
444    /// [`Self::ingest_from`] with no connection id (no per-connection quota).
445    pub fn ingest(
446        &self,
447        ctx: &PeerCtx,
448        wire: FactsWire,
449        now: u64,
450        replies: &mut Vec<FactsWire>,
451    ) -> Result<IngestOutcome, IngestError> {
452        self.ingest_from(ctx, 0, wire, now, replies)
453    }
454
455    /// Receive one facts message from connection `conn` (identity `ctx`), at
456    /// `now`. Replies to send back to the peer are appended to `replies`.
457    ///
458    /// The tier is derived from `ctx` on every frame: a node that is no
459    /// longer admitted drops back to `Discovered` with its next frame (or
460    /// when its connection goes, see [`Self::start`]); only a tier an
461    /// operator set is kept.
462    pub fn ingest_from(
463        &self,
464        ctx: &PeerCtx,
465        conn: u64,
466        wire: FactsWire,
467        now: u64,
468        replies: &mut Vec<FactsWire>,
469    ) -> Result<IngestOutcome, IngestError> {
470        let cache = self.membership.facts();
471        let (tier, cap) = if ctx.node_verified {
472            (self.policy.verified_tier, self.policy.verified_cap)
473        } else {
474            (self.policy.unverified_tier, self.policy.unverified_cap)
475        };
476        match wire {
477            FactsWire::Request => {
478                replies.extend(self.current());
479                Ok(IngestOutcome::Requested)
480            }
481            FactsWire::Facts { signed } => {
482                let facts = crate::node_facts_advert::verify_node_facts(&signed, now)
483                    .map_err(CacheError::from)?;
484                self.check_subject(ctx, &facts.node_id)?;
485                // One unverified connection can claim any number of source ids:
486                // cap how many `Discovered` entries it may hold.
487                if tier == TrustTier::Discovered
488                    && conn != 0
489                    && cache.get(&facts.node_id, now).is_none_or(|h| h.origin != conn)
490                    && cache.discovered_from(conn, now) >= MAX_DISCOVERED_PER_CONN
491                {
492                    return Err(IngestError::Quota {
493                        conn,
494                        limit: MAX_DISCOVERED_PER_CONN,
495                    });
496                }
497                let outcome = cache.insert_remote(signed, tier, cap, conn, now)?;
498                // The tier actually held: an operator-set one wins.
499                let tier = cache.get(&facts.node_id, now).map_or(tier, |h| h.trust_tier);
500                Ok(IngestOutcome::Accepted {
501                    node_id: facts.node_id,
502                    tier,
503                    outcome,
504                })
505            }
506            FactsWire::Delta { signed } => {
507                let (delta, _) = verify_facts_delta(&signed, now).map_err(CacheError::from)?;
508                self.check_subject(ctx, &delta.node_id)?;
509                if let Err(e) = cache.apply_delta(&signed, now) {
510                    // Missing or older base than the delta names: ask for it.
511                    let behind = cache
512                        .get(&delta.node_id, now)
513                        .is_none_or(|c| c.facts.seq < delta.base_seq);
514                    if behind {
515                        replies.push(FactsWire::Request);
516                    }
517                    return Err(e.into());
518                }
519                Ok(IngestOutcome::DeltaApplied {
520                    node_id: delta.node_id,
521                })
522            }
523        }
524    }
525
526    fn check_subject(&self, ctx: &PeerCtx, subject: &str) -> Result<(), IngestError> {
527        if subject == self.node_id {
528            return Err(IngestError::LocalNode);
529        }
530        if subject != ctx.peer_id {
531            return Err(IngestError::NotSender {
532                subject: subject.to_string(),
533                sender: ctx.peer_id.clone(),
534            });
535        }
536        Ok(())
537    }
538}
539
540impl PeerControlSink for FactsExchange {
541    fn on_peer_control(
542        &self,
543        ctx: &PeerCtx,
544        conn: u64,
545        payload: &serde_json::Value,
546    ) -> Vec<serde_json::Value> {
547        // Budget first: nothing below (parse, signature check) runs for a
548        // connection that is over its frame or byte budget. The size is read
549        // off the signed payload string, which is what the verify cost follows.
550        let size = json_size(payload);
551        if !self.allow(conn, size) {
552            tracing::debug!(peer = %ctx.peer_id, conn, "node facts frame dropped: over budget");
553            return Vec::new();
554        }
555        let wire: FactsWire = match serde_json::from_value(payload.clone()) {
556            Ok(w) => w,
557            Err(e) => {
558                tracing::warn!(peer = %ctx.peer_id, error = %e, "malformed node facts message");
559                return Vec::new();
560            }
561        };
562        let mut replies = Vec::new();
563        if let Err(e) = self.ingest_from(ctx, conn, wire, Self::now(), &mut replies) {
564            if matches!(
565                &e,
566                IngestError::Cache(CacheError::Verify(NodeFactsAdvertError::Facts(
567                    clawft_types::placement::FactsError::FromFuture { .. }
568                )))
569            ) {
570                tracing::warn!(peer = %ctx.peer_id, error = %e,
571                    "node facts refused: issued in the future, the peer's clock is ahead of ours \
572                     by more than the allowed skew");
573            } else {
574                tracing::warn!(peer = %ctx.peer_id, verified = ctx.node_verified, error = %e,
575                    "node facts refused");
576            }
577        }
578        replies.iter().filter_map(FactsWire::to_value).collect()
579    }
580}
581
582/// Size of a JSON value in bytes (strings, keys and scalars), walked without
583/// allocating: the budget is charged for the whole frame, not for one field.
584fn json_size(v: &serde_json::Value) -> usize {
585    match v {
586        serde_json::Value::String(s) => s.len() + 2,
587        serde_json::Value::Array(a) => 2 + a.iter().map(|x| json_size(x) + 1).sum::<usize>(),
588        serde_json::Value::Object(o) => {
589            2 + o.iter().map(|(k, x)| k.len() + 4 + json_size(x)).sum::<usize>()
590        }
591        _ => 8,
592    }
593}
594
595/// True when `a` and `b` differ only in live state (capability states and
596/// the free-memory attribute), so a delta can express the change.
597fn same_shape(a: &NodeFacts, b: &NodeFacts) -> bool {
598    let norm = |f: &NodeFacts| {
599        let mut c = f.capabilities.clone();
600        for cap in &mut c {
601            cap.state = CapabilityState::Available;
602            if matches!(cap.id.as_str(), "mem.unified" | "mem.system") {
603                cap.attrs.remove("free");
604            }
605        }
606        (c, f.ttl_secs)
607    };
608    norm(a) == norm(b)
609}
610
611/// The delta that takes `base` to `now_state` (same shape).
612fn live_delta(base: &NodeFacts, now_state: &NodeFacts, seq: u64, now: u64) -> FactsDelta {
613    let changes = base
614        .capabilities
615        .iter()
616        .zip(&now_state.capabilities)
617        .enumerate()
618        .filter(|(_, (b, n))| b.state != n.state)
619        .map(|(i, (_, n))| StateChange {
620            index: i as u32,
621            id: n.id.clone(),
622            state: n.state,
623        })
624        .collect();
625    let free = |f: &NodeFacts| f.load().mem_free;
626    let mem_free = (free(base) != free(now_state)).then(|| free(now_state)).flatten();
627    FactsDelta {
628        node_id: base.node_id.clone(),
629        base_seq: base.seq,
630        seq,
631        issued_at: now,
632        changes,
633        mem_free,
634    }
635}
636
637#[cfg(test)]
638#[path = "node_facts_exchange_tests.rs"]
639mod tests;