Skip to main content

clawft_kernel/
node_registry.rs

1//! Node identity registry.
2//!
3//! A **Node** is a physical thing in the mesh — an ESP32, the daemon
4//! host, a Pi. It signs *emissions* (sensor data, heartbeats, anything
5//! it merely reports). Sensing is not acting; nodes are deliberately
6//! distinct from actors ([`crate::agent_registry::AgentRegistry`]),
7//! which sign *Actions* (Foundry-style mutations).
8//!
9//! Each node holds an Ed25519 keypair. The node-id is
10//! `hex(SHA-256(pubkey)[..16])` (32 hex chars, ADR-103 D11), computed by
11//! [`crate::node_id::node_id_from_pubkey`] — self-authenticating
12//! (recompute to verify), and a 32-hex segment can never equal a
13//! reserved word like `_derived` or `meta`. A friendly human label lives as a property at
14//! `substrate/<node-id>/meta/label`, not as the identity itself —
15//! labels can collide, keys cannot.
16//!
17//! # Substrate write gate
18//!
19//! Under the node-identity contract every substrate write belongs to
20//! exactly one node and must land under that node's namespace:
21//!
22//! ```text
23//! substrate/<node-id>/...
24//! ```
25//!
26//! This module provides the registry (node-id → pubkey) and the
27//! canonical signing payload
28//! ([`node_publish_payload`]). Enforcement of the prefix rule lives
29//! on [`SubstrateService`][crate::substrate_service::SubstrateService];
30//! signature verification lives at the RPC boundary in
31//! `clawft-weave`. Both consult this registry.
32//!
33//! The registry is in-memory. Node keys are provisioned on the node
34//! side (firmware-burned for the ESP32, persisted on disk for the
35//! daemon) and registered at kernel boot. Restart-resets by design —
36//! the on-disk provisioning is the source of truth.
37
38use std::sync::Arc;
39use std::time::{SystemTime, UNIX_EPOCH};
40
41use chrono::{DateTime, Utc};
42use dashmap::DashMap;
43
44/// Scope of a [`DerivedWriteGrant`]. Decides whether the registered
45/// `topic` matches one literal subtree or all subtrees beneath it.
46///
47/// See `.planning/sensors/PIPELINE-PRIMITIVE-JOURNAL.md` §R3.6.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum GrantScope {
50    /// Grant covers exactly the path `substrate/_derived/<topic>` (no
51    /// children). Useful for single-leaf canonical facts like
52    /// `_derived/chain/head`.
53    ExactTopic,
54    /// Grant covers any path matching `substrate/_derived/<topic>/...`.
55    /// Used by pipelines that publish a per-source subtree (whisper:
56    /// `_derived/transcript/<source-node>/mic`).
57    TopicPrefix,
58}
59
60/// Capability allowing a node to publish under
61/// `substrate/_derived/<topic>` (or its subtree, depending on
62/// [`GrantScope`]).
63///
64/// Mesh-canonical paths (`substrate/_derived/...`) are explicitly
65/// outside the per-node prefix rule; without a grant the
66/// substrate write gate refuses every publish to that subtree, even
67/// from a daemon-class node. See
68/// `.planning/sensors/PIPELINE-PRIMITIVE-JOURNAL.md` §R3.6 for the
69/// design rationale.
70///
71/// **MVP scope:** grants are issued in-process by the daemon to
72/// itself; there is no signature on the grant itself, no revocation
73/// API, and no cross-node federation. Those land later (R3.6 calls
74/// them out as deferred).
75#[derive(Debug, Clone)]
76pub struct DerivedWriteGrant {
77    /// Node id permitted to write under the topic.
78    pub grantee_node_id: String,
79    /// Topic segment immediately under `_derived/`. For whisper this
80    /// is `"transcript"`. Stored without the `_derived/` prefix so
81    /// the registry holds the conceptual capability, not its path
82    /// rendering.
83    pub topic: String,
84    /// Wall-clock issuance time in milliseconds since UNIX epoch.
85    pub issued_at_ms: u64,
86    /// Whether the topic is matched as exact-only or as a path
87    /// prefix.
88    pub scope: GrantScope,
89}
90
91/// A registered node: node-id, public key, optional friendly label,
92/// and when it was added to the registry.
93#[derive(Debug, Clone)]
94pub struct RegisteredNode {
95    /// Stable identifier derived from the pubkey. Hex-encoded prefix
96    /// of SHA-256(pubkey). See [`node_id_from_pubkey`].
97    pub node_id: String,
98    /// Ed25519 public key (32 bytes).
99    pub pubkey: [u8; 32],
100    /// Optional human-readable label, e.g. `"esp32-workbench"` or
101    /// `"daemon-wsl"`. Authoritative label lives under
102    /// `substrate/<node-id>/meta/label`; this field is a convenience
103    /// copy so code paths that don't touch substrate can still render
104    /// a friendly name.
105    pub label: Option<String>,
106    /// When the node was registered with the kernel.
107    pub registered_at: DateTime<Utc>,
108}
109
110/// In-memory node-id → public-key map.
111///
112/// Cheap to clone — the inner map is wrapped in `Arc`/`DashMap`.
113///
114/// Also holds the [`DerivedWriteGrant`] table — the mesh-canonical
115/// write-permissions seam. Both tables share the registry's clone
116/// semantics so a service handed a `NodeRegistry` clone sees grant
117/// updates the daemon issues mid-boot.
118#[derive(Debug, Default, Clone)]
119pub struct NodeRegistry {
120    inner: Arc<DashMap<String, RegisteredNode>>,
121    /// `(grantee_node_id, topic)` → grant. Per-pair so the same node
122    /// holding two grants (e.g. `transcript` + `classify`) gets two
123    /// distinct rows; that matches the R3.6 mandate that grants are
124    /// path-bounded, not blanket.
125    grants: Arc<DashMap<(String, String), DerivedWriteGrant>>,
126}
127
128/// Failure modes for [`NodeRegistry::issue_derived_grant`].
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum DerivedGrantError {
131    /// The topic is empty or contains a `/` — topics name a single
132    /// segment immediately below `_derived/`. Multi-segment grants
133    /// must use [`GrantScope::TopicPrefix`] over the *first* segment
134    /// and rely on path matching for finer granularity.
135    InvalidTopic {
136        /// The offending topic string.
137        topic: String,
138    },
139}
140
141impl std::fmt::Display for DerivedGrantError {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        match self {
144            DerivedGrantError::InvalidTopic { topic } => write!(
145                f,
146                "invalid derived-write topic {topic:?}: must be one non-empty path segment"
147            ),
148        }
149    }
150}
151
152impl std::error::Error for DerivedGrantError {}
153
154impl NodeRegistry {
155    /// Create a new empty registry.
156    pub fn new() -> Self {
157        Self::default()
158    }
159
160    /// Register a node. The node-id is derived deterministically from
161    /// the pubkey — re-registering the same key updates the label but
162    /// keeps the id stable. Returns the (possibly-updated) entry.
163    pub fn register(&self, pubkey: [u8; 32], label: Option<String>) -> RegisteredNode {
164        let node_id = node_id_from_pubkey(&pubkey);
165        let entry = RegisteredNode {
166            node_id: node_id.clone(),
167            pubkey,
168            label,
169            registered_at: Utc::now(),
170        };
171        self.inner.insert(node_id, entry.clone());
172        entry
173    }
174
175    /// Look up a node by its id.
176    pub fn get(&self, node_id: &str) -> Option<RegisteredNode> {
177        self.inner.get(node_id).map(|e| e.clone())
178    }
179
180    /// Whether `node_id` is known.
181    pub fn contains(&self, node_id: &str) -> bool {
182        self.inner.contains_key(node_id)
183    }
184
185    /// Number of registered nodes.
186    pub fn len(&self) -> usize {
187        self.inner.len()
188    }
189
190    /// Whether the registry is empty.
191    pub fn is_empty(&self) -> bool {
192        self.inner.is_empty()
193    }
194
195    /// List all registered nodes.
196    pub fn list(&self) -> Vec<RegisteredNode> {
197        self.inner.iter().map(|e| e.value().clone()).collect()
198    }
199
200    /// Issue a [`DerivedWriteGrant`] permitting `grantee_node_id` to
201    /// publish under the mesh-canonical topic.
202    ///
203    /// **MVP authority model.** Per R3.6 the daemon issues to itself
204    /// in-process; there's no signature check at this seam. Future
205    /// federated grants will add an "issuer signature" arm that
206    /// `clawft-weave`'s RPC boundary will check before this call.
207    /// Today, callers are trusted because they had to be inside the
208    /// daemon process to reach the registry.
209    ///
210    /// Idempotent: re-issuing a `(grantee, topic)` pair overwrites
211    /// `issued_at_ms` and `scope`. Returns the freshly-stored grant.
212    pub fn issue_derived_grant(
213        &self,
214        grantee_node_id: impl Into<String>,
215        topic: impl Into<String>,
216        scope: GrantScope,
217    ) -> Result<DerivedWriteGrant, DerivedGrantError> {
218        let grantee_node_id = grantee_node_id.into();
219        let topic = topic.into();
220        if topic.is_empty() || topic.contains('/') {
221            return Err(DerivedGrantError::InvalidTopic { topic });
222        }
223        let issued_at_ms = SystemTime::now()
224            .duration_since(UNIX_EPOCH)
225            .map(|d| d.as_millis() as u64)
226            .unwrap_or(0);
227        let grant = DerivedWriteGrant {
228            grantee_node_id: grantee_node_id.clone(),
229            topic: topic.clone(),
230            issued_at_ms,
231            scope,
232        };
233        self.grants.insert((grantee_node_id, topic), grant.clone());
234        Ok(grant)
235    }
236
237    /// Whether `grantee_node_id` holds a grant covering `path`.
238    ///
239    /// `path` must look like `substrate/_derived/<topic>/...` (or
240    /// `substrate/_derived/<topic>` for [`GrantScope::ExactTopic`]).
241    /// Anything else returns `false`. The check is the second half
242    /// of the mesh-canonical write gate — the first half is the
243    /// `substrate/_derived/` tier detection that lives on
244    /// [`crate::SubstrateService::publish_gated_with_grants`].
245    pub fn has_derived_grant(&self, grantee_node_id: &str, path: &str) -> bool {
246        // Strip the mesh-canonical tier prefix; if the path doesn't
247        // belong to the tier, no grant matches.
248        let Some(tail) = path.strip_prefix(MESH_CANONICAL_PREFIX) else {
249            return false;
250        };
251        if tail.is_empty() {
252            return false;
253        }
254        // First segment of the tail is the topic; the rest (if any)
255        // is the grantee-controlled subtree.
256        let (topic, rest) = match tail.split_once('/') {
257            Some((t, r)) => (t, Some(r)),
258            None => (tail, None),
259        };
260        if topic.is_empty() {
261            return false;
262        }
263        let Some(entry) = self
264            .grants
265            .get(&(grantee_node_id.to_string(), topic.to_string()))
266        else {
267            return false;
268        };
269        match entry.scope {
270            // Exact: the path must be the bare topic, no subtree.
271            GrantScope::ExactTopic => rest.is_none(),
272            // Prefix: the path must be the topic followed by at least
273            // one non-empty subtree segment. A bare topic write
274            // `substrate/_derived/transcript` is rejected — that path
275            // belongs to the topic root, not to any pipeline-owned
276            // attribution subtree, and would collide if two
277            // pipelines ever shared a topic.
278            GrantScope::TopicPrefix => rest.is_some_and(|r| !r.is_empty()),
279        }
280    }
281
282    /// Snapshot of all currently-issued grants. Test helper / future
283    /// admin-RPC seam; not intended for hot-path use.
284    pub fn list_derived_grants(&self) -> Vec<DerivedWriteGrant> {
285        self.grants.iter().map(|e| e.value().clone()).collect()
286    }
287}
288
289/// Required path prefix for a mesh-canonical write. Trailing slash
290/// included so a `strip_prefix` cleanly leaves the remaining `<topic>`
291/// segment without a leading separator. See R3.0.
292pub const MESH_CANONICAL_PREFIX: &str = "substrate/_derived/";
293
294pub use crate::node_id::node_id_from_pubkey;
295
296/// Compose the canonical byte payload a node must sign as the
297/// proof-of-possession for `node.register`.
298///
299/// Layout:
300/// `b"node.register\0" || pubkey || b"\0" || ts_le || b"\0" || label`.
301///
302/// The label is included in the payload so a registration cannot be
303/// silently relabelled by anyone other than the keyholder. Empty
304/// label is accepted (and signs as a zero-length suffix).
305pub fn node_register_payload(pubkey: &[u8; 32], ts: u64, label: &str) -> Vec<u8> {
306    let mut buf = Vec::with_capacity(14 + 32 + 1 + 8 + 1 + label.len());
307    buf.extend_from_slice(b"node.register\0");
308    buf.extend_from_slice(pubkey);
309    buf.push(0);
310    buf.extend_from_slice(&ts.to_le_bytes());
311    buf.push(0);
312    buf.extend_from_slice(label.as_bytes());
313    buf
314}
315
316/// Compose the canonical byte payload a node must sign for a
317/// `substrate.publish`.
318///
319/// Layout:
320/// `b"substrate.publish.node\0" || path || b"\0" || message || b"\0" || ts_le || b"\0" || node_id`.
321///
322/// Separate from [`crate::agent_registry::publish_payload`] so an
323/// actor signature over an ipc.publish payload cannot be replayed as
324/// a node signature over a substrate.publish — the domain-separation
325/// prefix differs.
326pub fn node_publish_payload(path: &str, message: &str, ts: u64, node_id: &str) -> Vec<u8> {
327    let mut buf =
328        Vec::with_capacity(22 + path.len() + 1 + message.len() + 1 + 8 + 1 + node_id.len());
329    buf.extend_from_slice(b"substrate.publish.node\0");
330    buf.extend_from_slice(path.as_bytes());
331    buf.push(0);
332    buf.extend_from_slice(message.as_bytes());
333    buf.push(0);
334    buf.extend_from_slice(&ts.to_le_bytes());
335    buf.push(0);
336    buf.extend_from_slice(node_id.as_bytes());
337    buf
338}
339
340/// Required path prefix for any substrate write originated by `node_id`.
341///
342/// Returns `substrate/<node-id>/` (with the trailing slash so a
343/// `starts_with` check correctly rejects e.g.
344/// `substrate/<node-id-prefix-collision>/…`).
345pub fn required_path_prefix(node_id: &str) -> String {
346    format!("substrate/{node_id}/")
347}
348
349/// Whether `path` is a legal write target for `node_id`. Convenience
350/// wrapper over [`required_path_prefix`].
351pub fn path_belongs_to(path: &str, node_id: &str) -> bool {
352    path.starts_with(&required_path_prefix(node_id))
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn node_id_is_stable_per_pubkey() {
361        let pk = [7u8; 32];
362        let a = node_id_from_pubkey(&pk);
363        let b = node_id_from_pubkey(&pk);
364        assert_eq!(a, b);
365        assert_eq!(a.len(), 32, "32 hex chars (16 bytes of SHA-256)");
366        assert!(crate::node_id::is_node_id(&a));
367    }
368
369    #[test]
370    fn node_id_differs_per_pubkey() {
371        let a = node_id_from_pubkey(&[1u8; 32]);
372        let b = node_id_from_pubkey(&[2u8; 32]);
373        assert_ne!(a, b);
374    }
375
376    #[test]
377    fn register_and_lookup_round_trips() {
378        let reg = NodeRegistry::new();
379        let entry = reg.register([7u8; 32], Some("esp32-workbench".into()));
380        assert_eq!(reg.len(), 1);
381        let fetched = reg.get(&entry.node_id).unwrap();
382        assert_eq!(fetched.pubkey, [7u8; 32]);
383        assert_eq!(fetched.label.as_deref(), Some("esp32-workbench"));
384    }
385
386    #[test]
387    fn reregister_same_key_updates_label_keeps_id() {
388        let reg = NodeRegistry::new();
389        let a = reg.register([9u8; 32], Some("old".into()));
390        let b = reg.register([9u8; 32], Some("new".into()));
391        assert_eq!(a.node_id, b.node_id);
392        assert_eq!(reg.len(), 1);
393        assert_eq!(reg.get(&a.node_id).unwrap().label.as_deref(), Some("new"));
394    }
395
396    #[test]
397    fn contains_reports_membership() {
398        let reg = NodeRegistry::new();
399        let e = reg.register([3u8; 32], None);
400        assert!(reg.contains(&e.node_id));
401        assert!(!reg.contains("deadbeef"));
402    }
403
404    #[test]
405    fn list_returns_every_entry() {
406        let reg = NodeRegistry::new();
407        reg.register([1u8; 32], None);
408        reg.register([2u8; 32], None);
409        reg.register([3u8; 32], None);
410        let all = reg.list();
411        assert_eq!(all.len(), 3);
412    }
413
414    #[test]
415    fn publish_payload_is_deterministic_per_inputs() {
416        let p1 = node_publish_payload("substrate/n1/sensor/mic", "{}", 42, "n1");
417        let p2 = node_publish_payload("substrate/n1/sensor/mic", "{}", 42, "n1");
418        assert_eq!(p1, p2);
419    }
420
421    #[test]
422    fn publish_payload_changes_with_ts() {
423        let p1 = node_publish_payload("substrate/n1/x", "v", 1, "n1");
424        let p2 = node_publish_payload("substrate/n1/x", "v", 2, "n1");
425        assert_ne!(p1, p2);
426    }
427
428    #[test]
429    fn publish_payload_domain_separated_from_actor() {
430        // The actor-level `publish_payload` uses `b"ipc.publish\0"`
431        // as its prefix. Ours uses `b"substrate.publish.node\0"`.
432        // The two byte streams must not collide on any input.
433        let node_p = node_publish_payload("t", "v", 1, "n1");
434        let actor_p = crate::agent_registry::publish_payload("t", "v", 1, "n1");
435        assert_ne!(node_p, actor_p);
436    }
437
438    #[test]
439    fn required_prefix_includes_trailing_slash() {
440        assert_eq!(required_path_prefix("n1"), "substrate/n1/");
441    }
442
443    #[test]
444    fn path_belongs_to_accepts_exact_subtree() {
445        assert!(path_belongs_to("substrate/n1/sensor/mic", "n1"));
446        assert!(path_belongs_to("substrate/n1/meta/label", "n1"));
447    }
448
449    #[test]
450    fn path_belongs_to_rejects_cross_node() {
451        assert!(!path_belongs_to("substrate/n2/sensor/mic", "n1"));
452    }
453
454    #[test]
455    fn path_belongs_to_rejects_prefix_collision() {
456        // A node named `n1` must not be able to write under `n11`'s
457        // namespace just because `starts_with("substrate/n1")` is true.
458        // The trailing-slash guard blocks this.
459        assert!(!path_belongs_to("substrate/n11/anything", "n1"));
460    }
461
462    #[test]
463    fn path_belongs_to_rejects_top_level() {
464        assert!(!path_belongs_to("substrate/sensor/mic", "n1"));
465        assert!(!path_belongs_to("", "n1"));
466        assert!(!path_belongs_to("something-else", "n1"));
467    }
468
469    // ── DerivedWriteGrant ──────────────────────────────────────────
470
471    #[test]
472    fn issue_grant_round_trips() {
473        let reg = NodeRegistry::new();
474        let g = reg
475            .issue_derived_grant("n-daemon", "transcript", GrantScope::TopicPrefix)
476            .expect("valid grant accepted");
477        assert_eq!(g.grantee_node_id, "n-daemon");
478        assert_eq!(g.topic, "transcript");
479        assert_eq!(g.scope, GrantScope::TopicPrefix);
480        // Issuance time is recorded; we can't pin the exact value but
481        // it must be non-zero on any sane host clock.
482        assert!(g.issued_at_ms > 0);
483        assert_eq!(reg.list_derived_grants().len(), 1);
484    }
485
486    #[test]
487    fn issue_grant_rejects_invalid_topic() {
488        let reg = NodeRegistry::new();
489        assert!(matches!(
490            reg.issue_derived_grant("n-d", "", GrantScope::ExactTopic),
491            Err(DerivedGrantError::InvalidTopic { .. })
492        ));
493        assert!(matches!(
494            reg.issue_derived_grant("n-d", "transcript/mic", GrantScope::TopicPrefix),
495            Err(DerivedGrantError::InvalidTopic { .. })
496        ));
497    }
498
499    #[test]
500    fn has_grant_topic_prefix_matches_subtree() {
501        let reg = NodeRegistry::new();
502        reg.issue_derived_grant("n-daemon", "transcript", GrantScope::TopicPrefix)
503            .unwrap();
504        assert!(reg.has_derived_grant("n-daemon", "substrate/_derived/transcript/n-foo/mic"));
505        assert!(reg.has_derived_grant("n-daemon", "substrate/_derived/transcript/n-bar/cam"));
506        // Bare-topic write under a TopicPrefix grant is rejected;
507        // pipelines own attribution subtrees, not the topic root.
508        assert!(!reg.has_derived_grant("n-daemon", "substrate/_derived/transcript"));
509    }
510
511    #[test]
512    fn has_grant_exact_topic_rejects_subtree() {
513        let reg = NodeRegistry::new();
514        reg.issue_derived_grant("n-leader", "chain", GrantScope::ExactTopic)
515            .unwrap();
516        // ExactTopic covers exactly `substrate/_derived/chain`, not
517        // its subtree. (Real chain head is a single-leaf canonical
518        // value — see R3.1.)
519        assert!(reg.has_derived_grant("n-leader", "substrate/_derived/chain"));
520        assert!(!reg.has_derived_grant("n-leader", "substrate/_derived/chain/head"));
521    }
522
523    #[test]
524    fn has_grant_returns_false_when_grant_absent() {
525        let reg = NodeRegistry::new();
526        // No grants issued.
527        assert!(!reg.has_derived_grant("n-daemon", "substrate/_derived/transcript/n-foo/mic"));
528    }
529
530    #[test]
531    fn has_grant_rejects_other_grantee() {
532        let reg = NodeRegistry::new();
533        reg.issue_derived_grant("n-daemon", "transcript", GrantScope::TopicPrefix)
534            .unwrap();
535        assert!(!reg.has_derived_grant("n-other", "substrate/_derived/transcript/n-foo/mic"));
536    }
537
538    #[test]
539    fn has_grant_rejects_unrelated_topic() {
540        let reg = NodeRegistry::new();
541        reg.issue_derived_grant("n-daemon", "transcript", GrantScope::TopicPrefix)
542            .unwrap();
543        // Same grantee, different topic.
544        assert!(!reg.has_derived_grant("n-daemon", "substrate/_derived/classify/n-foo/mic"));
545    }
546
547    #[test]
548    fn has_grant_rejects_non_mesh_canonical_path() {
549        let reg = NodeRegistry::new();
550        reg.issue_derived_grant("n-daemon", "transcript", GrantScope::TopicPrefix)
551            .unwrap();
552        // Even with a grant, paths that don't sit under
553        // `substrate/_derived/` belong to the node-private tier and
554        // must not be matched by this check.
555        assert!(!reg.has_derived_grant("n-daemon", "substrate/n-daemon/foo"));
556        assert!(!reg.has_derived_grant("n-daemon", "substrate/_derived/"));
557        assert!(!reg.has_derived_grant("n-daemon", ""));
558    }
559
560    #[test]
561    fn issue_grant_is_idempotent_per_pair() {
562        let reg = NodeRegistry::new();
563        let _ = reg
564            .issue_derived_grant("n-daemon", "transcript", GrantScope::TopicPrefix)
565            .unwrap();
566        // Second issue of same (grantee, topic) overwrites in place,
567        // doesn't grow the table.
568        let _ = reg
569            .issue_derived_grant("n-daemon", "transcript", GrantScope::ExactTopic)
570            .unwrap();
571        assert_eq!(reg.list_derived_grants().len(), 1);
572        // After the overwrite the path with subtree no longer matches
573        // (scope flipped to ExactTopic).
574        assert!(!reg.has_derived_grant("n-daemon", "substrate/_derived/transcript/n-foo/mic"));
575    }
576}