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}