1use std::sync::Arc;
34use std::sync::atomic::{AtomicU64, Ordering};
35
36use std::sync::Mutex;
37
38use clawft_substrate::{
39 AclDenied, AclTable, CallerIdentity, READ_DENIED_EVENT, plan_publish_public,
40};
41use dashmap::DashMap;
42use serde::{Deserialize, Serialize};
43use serde_json::Value;
44use tokio::sync::mpsc;
45use tracing::{debug, warn};
46
47use crate::topic::{SubscriberId, SubscriberSink};
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
55#[serde(rename_all = "lowercase")]
56pub enum Sensitivity {
57 #[default]
59 Public,
60 Workspace,
62 Private,
64 Capture,
66}
67
68impl Sensitivity {
69 pub fn requires_caller_identity(self) -> bool {
74 matches!(self, Sensitivity::Capture)
75 }
76
77 pub fn as_str(self) -> &'static str {
79 match self {
80 Sensitivity::Public => "public",
81 Sensitivity::Workspace => "workspace",
82 Sensitivity::Private => "private",
83 Sensitivity::Capture => "capture",
84 }
85 }
86}
87
88struct Entry {
90 value: Option<Value>,
91 tick: u64,
92 sensitivity: Sensitivity,
93 sinks: Vec<(SubscriberId, SubscriberSink)>,
94}
95
96impl Entry {
97 fn new(sensitivity: Sensitivity) -> Self {
98 Self {
99 value: None,
100 tick: 0,
101 sensitivity,
102 sinks: Vec::new(),
103 }
104 }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct SubstrateReadSnapshot {
110 pub value: Option<Value>,
112 pub tick: u64,
114 pub sensitivity: Sensitivity,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct SubstrateListEntry {
126 pub path: String,
128 pub has_value: bool,
130 pub child_count: u32,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct SubstrateListSnapshot {
137 pub children: Vec<SubstrateListEntry>,
139 pub tick: u64,
142}
143
144#[derive(Debug, Clone)]
146pub struct EgressDenied {
147 pub reason: String,
149 pub acl: Option<AclDenied>,
151}
152
153impl EgressDenied {
154 pub fn sensitivity(reason: impl Into<String>) -> Self {
156 Self {
157 reason: reason.into(),
158 acl: None,
159 }
160 }
161
162 pub fn acl_denied(denied: AclDenied) -> Self {
164 let reason = denied.wire_message();
165 Self {
166 reason,
167 acl: Some(denied),
168 }
169 }
170
171 pub fn is_acl_denied(&self) -> bool {
173 self.acl.is_some() || self.reason.starts_with(AclDenied::CODE)
174 }
175
176 pub fn wire_message(&self) -> String {
179 if let Some(ref a) = self.acl {
180 a.wire_message()
181 } else if self.reason.starts_with(AclDenied::CODE) {
182 self.reason.clone()
183 } else {
184 format!("unauthorized: {}", self.reason)
185 }
186 }
187}
188
189impl std::fmt::Display for EgressDenied {
190 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191 f.write_str(&self.wire_message())
192 }
193}
194
195impl From<AclDenied> for EgressDenied {
196 fn from(d: AclDenied) -> Self {
197 Self::acl_denied(d)
198 }
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct AclDenialEvent {
204 pub kind: String,
206 pub payload: Value,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
218pub enum GateDenied {
219 WrongPrefix {
221 path: String,
223 node_id: String,
225 },
226 MissingNodeId {
228 path: String,
230 },
231 MissingDerivedGrant {
235 path: String,
237 node_id: String,
239 },
240}
241
242impl std::fmt::Display for GateDenied {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 match self {
245 GateDenied::WrongPrefix { path, node_id } => write!(
246 f,
247 "node {node_id} may not publish to {path} (must sit under substrate/{node_id}/)"
248 ),
249 GateDenied::MissingNodeId { path } => write!(
250 f,
251 "publish to {path} rejected: no node_id declared (every write must be node-attributed)"
252 ),
253 GateDenied::MissingDerivedGrant { path, node_id } => write!(
254 f,
255 "node {node_id} may not publish to mesh-canonical path {path} \
256 (no DerivedWriteGrant for the topic; see R3.6)"
257 ),
258 }
259 }
260}
261
262impl std::error::Error for GateDenied {}
263
264#[derive(Clone)]
266pub struct SubstrateService {
267 inner: Arc<SubstrateInner>,
268}
269
270struct SubstrateInner {
271 entries: DashMap<String, Entry>,
272 global_tick: AtomicU64,
273 acl: Mutex<AclTable>,
275 denial_log: Mutex<Vec<AclDenialEvent>>,
277}
278
279impl Default for SubstrateService {
280 fn default() -> Self {
281 Self::new()
282 }
283}
284
285impl SubstrateService {
286 pub fn new() -> Self {
290 Self::with_mesh_id("local")
291 }
292
293 pub fn with_mesh_id(mesh_id: impl Into<String>) -> Self {
295 Self {
296 inner: Arc::new(SubstrateInner {
297 entries: DashMap::new(),
298 global_tick: AtomicU64::new(0),
299 acl: Mutex::new(AclTable::with_boot_defaults(mesh_id)),
300 denial_log: Mutex::new(Vec::new()),
301 }),
302 }
303 }
304
305 pub fn set_acl_table(&self, table: AclTable) {
307 *self.inner.acl.lock().expect("acl mutex") = table;
308 }
309
310 pub fn publish_public(&self, path: &str, value: Value) -> u64 {
318 let mesh_id = self
319 .inner
320 .acl
321 .lock()
322 .expect("acl mutex")
323 .mesh_id()
324 .map(str::to_string);
325 let plan = plan_publish_public(path, mesh_id.as_deref());
326 self.inner
327 .acl
328 .lock()
329 .expect("acl mutex")
330 .publish_public(&plan.path);
331 self.publish(None, &plan.path, value)
332 }
333
334 pub fn take_acl_denials(&self) -> Vec<AclDenialEvent> {
337 std::mem::take(&mut *self.inner.denial_log.lock().expect("denial_log mutex"))
338 }
339
340 fn record_acl_denial(&self, denied: &AclDenied) {
342 if denied.caller == "anonymous" {
345 return;
346 }
347 self.inner
348 .denial_log
349 .lock()
350 .expect("denial_log mutex")
351 .push(AclDenialEvent {
352 kind: READ_DENIED_EVENT.to_string(),
353 payload: denied.chain_payload(),
354 });
355 }
356
357 pub fn declare(&self, path: &str, sensitivity: Sensitivity) {
363 let mut entry = self
364 .inner
365 .entries
366 .entry(path.to_string())
367 .or_insert_with(|| Entry::new(sensitivity));
368 entry.sensitivity = sensitivity;
369 }
370
371 pub fn has_published_under(&self, prefix: &str) -> bool {
376 let norm = normalize_prefix(prefix);
377 let child = format!("{norm}/");
378 self.inner
379 .entries
380 .iter()
381 .any(|r| r.value.is_some() && (*r.key() == norm || r.key().starts_with(&child)))
382 }
383
384 pub fn egress_check(
389 &self,
390 caller: Option<&str>,
391 path: &str,
392 op: &str,
393 ) -> Result<(), EgressDenied> {
394 let identity = CallerIdentity::parse(caller);
395
396 if let Err(denied) = self.inner.acl.lock().expect("acl mutex").check(path, &identity, op)
398 {
399 self.record_acl_denial(&denied);
400 warn!(
401 path,
402 caller = %denied.caller,
403 op,
404 reason = %denied.reason,
405 "substrate acl denied"
406 );
407 return Err(EgressDenied::acl_denied(denied));
408 }
409
410 let sensitivity = self
412 .inner
413 .entries
414 .get(path)
415 .map(|e| e.sensitivity)
416 .unwrap_or(Sensitivity::Workspace);
417
418 if sensitivity.requires_caller_identity() && caller.is_none() {
419 return Err(EgressDenied::sensitivity(format!(
420 "{op} on {path} (sensitivity={}) requires authenticated caller",
421 sensitivity.as_str()
422 )));
423 }
424
425 if sensitivity == Sensitivity::Capture {
426 warn!(
427 path,
428 caller = ?caller,
429 op,
430 "substrate egress: capture-tier path accessed"
431 );
432 } else {
433 debug!(
434 path,
435 caller = ?caller,
436 op,
437 sensitivity = sensitivity.as_str(),
438 "substrate egress ok"
439 );
440 }
441 Ok(())
442 }
443
444 pub fn read(
446 &self,
447 caller: Option<&str>,
448 path: &str,
449 ) -> Result<SubstrateReadSnapshot, EgressDenied> {
450 self.egress_check(caller, path, "read")?;
451 let snapshot = match self.inner.entries.get(path) {
452 Some(e) => SubstrateReadSnapshot {
453 value: e.value.clone(),
454 tick: e.tick,
455 sensitivity: e.sensitivity,
456 },
457 None => SubstrateReadSnapshot {
458 value: None,
459 tick: 0,
460 sensitivity: Sensitivity::Workspace,
461 },
462 };
463 Ok(snapshot)
464 }
465
466 pub fn list(
493 &self,
494 caller: Option<&str>,
495 prefix: &str,
496 depth: u32,
497 ) -> Result<SubstrateListSnapshot, EgressDenied> {
498 let norm_prefix = normalize_prefix(prefix);
499 self.egress_check(caller, &norm_prefix, "list")?;
502
503 let global_tick = self.inner.global_tick.load(Ordering::Relaxed);
504
505 let mut value_paths: Vec<(String, Sensitivity)> =
509 Vec::with_capacity(self.inner.entries.len());
510 for r in self.inner.entries.iter() {
511 if r.value.is_some() {
516 value_paths.push((r.key().clone(), r.sensitivity));
517 }
518 }
519
520 if depth == 0 {
522 if let Some((_, sens)) = value_paths.iter().find(|(p, _)| *p == norm_prefix) {
523 if egress_permits(caller, *sens) && list_path_visible(self, caller, &norm_prefix) {
525 let child_count =
526 count_descendants(self, &value_paths, &norm_prefix, caller);
527 return Ok(SubstrateListSnapshot {
528 children: vec![SubstrateListEntry {
529 path: norm_prefix,
530 has_value: true,
531 child_count,
532 }],
533 tick: global_tick,
534 });
535 }
536 }
537 return Ok(SubstrateListSnapshot {
538 children: Vec::new(),
539 tick: global_tick,
540 });
541 }
542
543 if norm_prefix.is_empty() {
548 } else if let Some((_, sens)) = value_paths.iter().find(|(p, _)| *p == norm_prefix)
550 && is_leaf(&value_paths, &norm_prefix)
551 && egress_permits(caller, *sens)
552 && list_path_visible(self, caller, &norm_prefix)
553 {
554 return Ok(SubstrateListSnapshot {
555 children: vec![SubstrateListEntry {
556 path: norm_prefix,
557 has_value: true,
558 child_count: 0,
559 }],
560 tick: global_tick,
561 });
562 }
563
564 use std::collections::BTreeMap;
568 let mut buckets: BTreeMap<String, bool> = BTreeMap::new();
569
570 for (path, sens) in &value_paths {
571 if !egress_permits(caller, *sens) {
572 continue;
573 }
574 if !list_path_visible(self, caller, path) {
575 continue;
576 }
577 let Some(rest) = strict_descendant_rest(path, &norm_prefix) else {
580 continue;
581 };
582 let tail_segs: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect();
584 if tail_segs.is_empty() {
585 continue;
586 }
587 let max_level = (depth as usize).min(tail_segs.len());
591 for level in 1..=max_level {
592 let ancestor = join_with_prefix(&norm_prefix, &tail_segs[..level]);
593 if !list_path_visible(self, caller, &ancestor) {
595 continue;
596 }
597 let is_exact_value_leaf = level == tail_segs.len();
598 let entry = buckets.entry(ancestor).or_insert(false);
599 if is_exact_value_leaf {
600 *entry = true;
601 }
602 }
603 }
604
605 let children: Vec<SubstrateListEntry> = buckets
606 .into_iter()
607 .map(|(path, has_value)| SubstrateListEntry {
608 child_count: count_descendants(self, &value_paths, &path, caller),
609 path,
610 has_value,
611 })
612 .collect();
613
614 Ok(SubstrateListSnapshot {
615 children,
616 tick: global_tick,
617 })
618 }
619
620 pub fn publish_gated(
644 &self,
645 node_id: Option<&str>,
646 path: &str,
647 value: Value,
648 ) -> Result<u64, GateDenied> {
649 let node_id = node_id.ok_or_else(|| GateDenied::MissingNodeId {
650 path: path.to_string(),
651 })?;
652 if !crate::node_registry::path_belongs_to(path, node_id) {
653 return Err(GateDenied::WrongPrefix {
654 path: path.to_string(),
655 node_id: node_id.to_string(),
656 });
657 }
658 Ok(self.publish_after_gate(node_id, path, value))
659 }
660
661 pub fn publish_gated_with_grants(
679 &self,
680 node_id: Option<&str>,
681 path: &str,
682 value: Value,
683 node_registry: &crate::NodeRegistry,
684 ) -> Result<u64, GateDenied> {
685 let node_id = node_id.ok_or_else(|| GateDenied::MissingNodeId {
686 path: path.to_string(),
687 })?;
688 if path.starts_with(crate::node_registry::MESH_CANONICAL_PREFIX) {
695 if !node_registry.has_derived_grant(node_id, path) {
696 return Err(GateDenied::MissingDerivedGrant {
697 path: path.to_string(),
698 node_id: node_id.to_string(),
699 });
700 }
701 return Ok(self.publish_after_gate(node_id, path, value));
702 }
703 if !crate::node_registry::path_belongs_to(path, node_id) {
704 return Err(GateDenied::WrongPrefix {
705 path: path.to_string(),
706 node_id: node_id.to_string(),
707 });
708 }
709 Ok(self.publish_after_gate(node_id, path, value))
710 }
711
712 fn publish_after_gate(&self, node_id: &str, path: &str, value: Value) -> u64 {
723 let new_tick = self.inner.global_tick.fetch_add(1, Ordering::Relaxed) + 1;
724 let line = build_update_line(path, Some(&value), new_tick, "publish", Some(node_id));
725 let mut entry = self
726 .inner
727 .entries
728 .entry(path.to_string())
729 .or_insert_with(|| Entry::new(Sensitivity::Workspace));
730 entry.value = Some(value);
731 entry.tick = new_tick;
732 fanout(&mut entry.sinks, &line);
733 new_tick
734 }
735
736 pub fn publish(&self, caller: Option<&str>, path: &str, value: Value) -> u64 {
747 let new_tick = self.inner.global_tick.fetch_add(1, Ordering::Relaxed) + 1;
748 let line = build_update_line(path, Some(&value), new_tick, "publish", caller);
749
750 let mut entry = self
751 .inner
752 .entries
753 .entry(path.to_string())
754 .or_insert_with(|| Entry::new(Sensitivity::Workspace));
755 entry.value = Some(value);
756 entry.tick = new_tick;
757 fanout(&mut entry.sinks, &line);
758 new_tick
759 }
760
761 pub fn notify(&self, caller: Option<&str>, path: &str) -> u64 {
764 let new_tick = self.inner.global_tick.fetch_add(1, Ordering::Relaxed) + 1;
765 let line = build_update_line(path, None, new_tick, "notify", caller);
766
767 let mut entry = self
768 .inner
769 .entries
770 .entry(path.to_string())
771 .or_insert_with(|| Entry::new(Sensitivity::Workspace));
772 entry.tick = new_tick;
773 fanout(&mut entry.sinks, &line);
774 new_tick
775 }
776
777 pub fn subscribe(
782 &self,
783 caller: Option<&str>,
784 path: &str,
785 ) -> Result<(SubscriberId, mpsc::Receiver<Vec<u8>>), EgressDenied> {
786 self.egress_check(caller, path, "subscribe")?;
787 let (tx, rx) = mpsc::channel::<Vec<u8>>(256);
788 let mut entry = self
789 .inner
790 .entries
791 .entry(path.to_string())
792 .or_insert_with(|| Entry::new(Sensitivity::Workspace));
793 let id = SubscriberId::next();
794 entry.sinks.push((id, SubscriberSink::ExternalStream(tx)));
795 Ok((id, rx))
796 }
797
798 pub fn unsubscribe(&self, path: &str, id: SubscriberId) {
800 if let Some(mut entry) = self.inner.entries.get_mut(path) {
801 entry.sinks.retain(|(existing, _)| *existing != id);
802 }
803 }
804
805 pub fn path_count(&self) -> usize {
807 self.inner.entries.len()
808 }
809}
810
811fn fanout(sinks: &mut Vec<(SubscriberId, SubscriberSink)>, line: &[u8]) {
812 let mut dead = Vec::new();
813 for (id, sink) in sinks.iter() {
814 match sink {
815 SubscriberSink::ExternalStream(tx) => {
816 if tx.try_send(line.to_vec()).is_err() {
817 if tx.is_closed() {
819 dead.push(*id);
820 }
821 }
822 }
823 SubscriberSink::PidInbox(_) => {
824 }
827 }
828 }
829 if !dead.is_empty() {
830 sinks.retain(|(id, _)| !dead.contains(id));
831 }
832}
833
834fn normalize_prefix(prefix: &str) -> String {
837 let trimmed = prefix.trim_matches('/');
838 trimmed.to_string()
839}
840
841fn strict_descendant_rest<'a>(path: &'a str, prefix: &str) -> Option<&'a str> {
845 if prefix.is_empty() {
846 if path.is_empty() {
849 return None;
850 }
851 return Some(path);
852 }
853 if path == prefix {
854 return None; }
856 let with_sep = path.strip_prefix(prefix)?;
857 let rest = with_sep.strip_prefix('/')?;
858 if rest.is_empty() { None } else { Some(rest) }
859}
860
861fn join_with_prefix(prefix: &str, segs: &[&str]) -> String {
864 if prefix.is_empty() {
865 segs.join("/")
866 } else {
867 let mut out = String::with_capacity(
868 prefix.len() + 1 + segs.iter().map(|s| s.len() + 1).sum::<usize>(),
869 );
870 out.push_str(prefix);
871 for s in segs {
872 out.push('/');
873 out.push_str(s);
874 }
875 out
876 }
877}
878
879fn is_leaf(value_paths: &[(String, Sensitivity)], path: &str) -> bool {
882 !value_paths
883 .iter()
884 .any(|(p, _)| strict_descendant_rest(p, path).is_some())
885}
886
887fn count_descendants(
890 svc: &SubstrateService,
891 value_paths: &[(String, Sensitivity)],
892 path: &str,
893 caller: Option<&str>,
894) -> u32 {
895 let mut n: u32 = 0;
896 for (p, sens) in value_paths {
897 if !egress_permits(caller, *sens) {
898 continue;
899 }
900 if !list_path_visible(svc, caller, p) {
901 continue;
902 }
903 if strict_descendant_rest(p, path).is_some() {
904 n = n.saturating_add(1);
905 }
906 }
907 n
908}
909
910fn egress_permits(caller: Option<&str>, sensitivity: Sensitivity) -> bool {
916 !(sensitivity.requires_caller_identity() && caller.is_none())
917}
918
919fn list_path_visible(svc: &SubstrateService, caller: Option<&str>, path: &str) -> bool {
921 svc.inner
922 .acl
923 .lock()
924 .expect("acl mutex")
925 .check_raw(path, caller, "list")
926 .is_ok()
927}
928
929fn build_update_line(
930 path: &str,
931 value: Option<&Value>,
932 tick: u64,
933 kind: &str,
934 caller: Option<&str>,
935) -> Vec<u8> {
936 let body = serde_json::json!({
937 "path": path,
938 "tick": tick,
939 "kind": kind,
940 "value": value,
941 "actor_id": caller,
942 });
943 let mut bytes = serde_json::to_vec(&body).unwrap_or_else(|_| b"{}".to_vec());
944 bytes.push(b'\n');
945 bytes
946}
947
948#[cfg(test)]
949mod tests {
950 use super::*;
951
952 #[test]
953 fn publish_then_read_roundtrip() {
954 let svc = SubstrateService::new();
955 let t = svc.publish(None, "substrate/test/ping", serde_json::json!(42));
956 let snap = svc.read(None, "substrate/test/ping").unwrap();
957 assert_eq!(snap.value, Some(serde_json::json!(42)));
958 assert_eq!(snap.tick, t);
959 }
960
961 #[test]
962 fn read_unknown_path_returns_empty() {
963 let svc = SubstrateService::new();
964 let snap = svc.read(None, "nope").unwrap();
965 assert!(snap.value.is_none());
966 assert_eq!(snap.tick, 0);
967 }
968
969 #[tokio::test]
970 async fn subscribe_receives_publish_and_notify() {
971 let svc = SubstrateService::new();
972 let (_id, mut rx) = svc.subscribe(None, "substrate/test/ping").unwrap();
973 svc.publish(None, "substrate/test/ping", serde_json::json!("hi"));
974 svc.notify(None, "substrate/test/ping");
975
976 let line1 = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
977 .await
978 .unwrap()
979 .unwrap();
980 let v1: serde_json::Value = serde_json::from_slice(&line1[..line1.len() - 1]).unwrap();
981 assert_eq!(v1["kind"], "publish");
982
983 let line2 = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
984 .await
985 .unwrap()
986 .unwrap();
987 let v2: serde_json::Value = serde_json::from_slice(&line2[..line2.len() - 1]).unwrap();
988 assert_eq!(v2["kind"], "notify");
989 assert!(v2["value"].is_null());
990 }
991
992 #[test]
993 fn egress_check_denies_capture_anonymous() {
994 let svc = SubstrateService::new();
995 svc.declare("substrate/mic/frames", Sensitivity::Capture);
996 let err = svc.read(None, "substrate/mic/frames").unwrap_err();
997 assert!(err.reason.contains("requires authenticated") || err.wire_message().contains("requires authenticated"));
998 }
999
1000 #[test]
1001 fn egress_check_allows_capture_with_identity() {
1002 let svc = SubstrateService::new();
1003 svc.declare("substrate/mic/frames", Sensitivity::Capture);
1004 assert!(svc.read(Some("aid-1"), "substrate/mic/frames").is_ok());
1005 }
1006
1007 #[test]
1010 fn acl_sensor_denies_anonymous_and_foreign_actor() {
1011 let svc = SubstrateService::with_mesh_id("mesh-test");
1012 let path = "substrate/n-esp32/sensor/mic/pcm_chunk";
1013 svc.publish(Some("n-esp32"), path, serde_json::json!({"pcm": true}));
1014
1015 let err = svc.read(None, path).unwrap_err();
1016 assert!(err.is_acl_denied(), "expected acl_denied, got: {}", err);
1017 assert!(err.wire_message().starts_with("acl_denied:"));
1018
1019 let err2 = svc
1020 .read(Some("actor:a-watch1"), path)
1021 .unwrap_err();
1022 assert!(err2.is_acl_denied());
1023 let events = svc.take_acl_denials();
1025 assert!(
1026 events.iter().any(|e| e.kind == clawft_substrate::READ_DENIED_EVENT),
1027 "expected substrate.read.denied event"
1028 );
1029 }
1030
1031 #[test]
1032 fn acl_sensor_allows_owning_node_and_admin() {
1033 let svc = SubstrateService::with_mesh_id("mesh-test");
1034 let path = "substrate/n-esp32/sensor/mic/pcm_chunk";
1035 svc.publish(Some("n-esp32"), path, serde_json::json!(1));
1036
1037 let snap = svc.read(Some("node:n-esp32"), path).unwrap();
1038 assert_eq!(snap.value, Some(serde_json::json!(1)));
1039
1040 let snap = svc.read(Some("scope:admin"), path).unwrap();
1041 assert_eq!(snap.value, Some(serde_json::json!(1)));
1042 }
1043
1044 #[test]
1045 fn acl_meta_public_sensor_private_integration() {
1046 let svc = SubstrateService::with_mesh_id("mesh-test");
1048 svc.publish(
1049 Some("n-esp32"),
1050 "substrate/n-esp32/meta",
1051 serde_json::json!({"fw": "1.0"}),
1052 );
1053 svc.publish(
1054 Some("n-esp32"),
1055 "substrate/n-esp32/sensor/mic/pcm_chunk",
1056 serde_json::json!({"chunk": 0}),
1057 );
1058
1059 let watch = Some("actor:a-watch1");
1060 let meta = svc.read(watch, "substrate/n-esp32/meta").unwrap();
1061 assert!(meta.value.is_some());
1062 assert!(svc
1063 .subscribe(watch, "substrate/n-esp32/sensor/mic/pcm_chunk")
1064 .is_err());
1065 }
1066
1067 #[test]
1068 fn acl_publish_public_opts_sensor_out() {
1069 let svc = SubstrateService::with_mesh_id("mesh-test");
1070 let path = "substrate/n-esp32/sensor/mic/rms";
1071 svc.publish_public(path, serde_json::json!({"rms_db": -20}));
1072 let snap = svc.read(None, path).unwrap();
1073 assert_eq!(snap.value, Some(serde_json::json!({"rms_db": -20})));
1074 }
1075
1076 #[test]
1077 fn acl_list_hides_private_sensor_children() {
1078 let svc = SubstrateService::with_mesh_id("mesh-test");
1079 svc.publish(
1080 Some("n-1"),
1081 "substrate/n-1/meta",
1082 serde_json::json!({"ok": true}),
1083 );
1084 svc.publish(
1085 Some("n-1"),
1086 "substrate/n-1/sensor/mic",
1087 serde_json::json!({"rms": 1}),
1088 );
1089
1090 let list = svc
1091 .list(None, "substrate/n-1", 2)
1092 .unwrap();
1093 let paths: Vec<&str> = list.children.iter().map(|c| c.path.as_str()).collect();
1094 assert!(
1095 paths.iter().any(|p| p.ends_with("/meta")),
1096 "meta should be listed: {paths:?}"
1097 );
1098 assert!(
1099 !paths.iter().any(|p| p.contains("/sensor")),
1100 "sensor must not leak to anonymous list: {paths:?}"
1101 );
1102 }
1103
1104 #[test]
1105 fn tick_monotonic_across_ops() {
1106 let svc = SubstrateService::new();
1107 let a = svc.publish(None, "p", serde_json::json!(1));
1108 let b = svc.notify(None, "p");
1109 let c = svc.publish(None, "p", serde_json::json!(2));
1110 assert!(b > a);
1111 assert!(c > b);
1112 }
1113
1114 fn seed_sensor_substrate(svc: &SubstrateService) {
1117 svc.publish(
1118 None,
1119 "substrate/sensor/mic",
1120 serde_json::json!({"rms_db": -20}),
1121 );
1122 svc.publish(
1123 None,
1124 "substrate/sensor/tof",
1125 serde_json::json!({"frame": 1}),
1126 );
1127 svc.publish(
1128 None,
1129 "substrate/sensor/mic/history",
1130 serde_json::json!([1, 2, 3]),
1131 );
1132 svc.publish(
1133 None,
1134 "substrate/kernel/status",
1135 serde_json::json!({"state": "running"}),
1136 );
1137 }
1138
1139 #[test]
1140 fn list_empty_store_returns_empty_children() {
1141 let svc = SubstrateService::new();
1142 let snap = svc.list(None, "substrate", 1).unwrap();
1143 assert!(snap.children.is_empty());
1144 }
1145
1146 #[test]
1147 fn list_prefix_with_no_children_returns_empty() {
1148 let svc = SubstrateService::new();
1149 seed_sensor_substrate(&svc);
1150 let snap = svc.list(None, "substrate/nope", 1).unwrap();
1151 assert!(snap.children.is_empty());
1152 }
1153
1154 #[test]
1155 fn list_top_level_substrate() {
1156 let svc = SubstrateService::new();
1157 seed_sensor_substrate(&svc);
1158 let snap = svc.list(None, "substrate", 1).unwrap();
1159 let paths: Vec<&str> = snap.children.iter().map(|c| c.path.as_str()).collect();
1161 assert_eq!(paths, vec!["substrate/kernel", "substrate/sensor"]);
1162 assert!(snap.children.iter().all(|c| !c.has_value));
1164 let sensor = snap
1166 .children
1167 .iter()
1168 .find(|c| c.path == "substrate/sensor")
1169 .unwrap();
1170 assert_eq!(sensor.child_count, 3);
1172 }
1173
1174 #[test]
1175 fn list_depth_one_under_sensor_groups_children() {
1176 let svc = SubstrateService::new();
1177 seed_sensor_substrate(&svc);
1178 let snap = svc.list(None, "substrate/sensor", 1).unwrap();
1179 let mic = snap
1180 .children
1181 .iter()
1182 .find(|c| c.path == "substrate/sensor/mic")
1183 .expect("mic child present");
1184 let tof = snap
1185 .children
1186 .iter()
1187 .find(|c| c.path == "substrate/sensor/tof")
1188 .expect("tof child present");
1189 assert!(mic.has_value, "mic has a direct value");
1190 assert!(tof.has_value, "tof has a direct value");
1191 assert_eq!(mic.child_count, 1);
1193 assert_eq!(tof.child_count, 0);
1194 }
1195
1196 #[test]
1197 fn list_depth_two_returns_flat_descendants() {
1198 let svc = SubstrateService::new();
1199 seed_sensor_substrate(&svc);
1200 let snap = svc.list(None, "substrate/sensor", 2).unwrap();
1201 let paths: Vec<&str> = snap.children.iter().map(|c| c.path.as_str()).collect();
1202 assert!(paths.contains(&"substrate/sensor/mic"));
1204 assert!(paths.contains(&"substrate/sensor/mic/history"));
1205 assert!(paths.contains(&"substrate/sensor/tof"));
1206 }
1207
1208 #[test]
1209 fn list_depth_zero_returns_just_the_prefix_node() {
1210 let svc = SubstrateService::new();
1211 seed_sensor_substrate(&svc);
1212 let snap = svc.list(None, "substrate/sensor/mic", 0).unwrap();
1214 assert_eq!(snap.children.len(), 1);
1215 assert_eq!(snap.children[0].path, "substrate/sensor/mic");
1216 assert!(snap.children[0].has_value);
1217 assert_eq!(snap.children[0].child_count, 1);
1218
1219 let snap = svc.list(None, "substrate/sensor", 0).unwrap();
1221 assert!(snap.children.is_empty());
1222 }
1223
1224 #[test]
1225 fn list_leaf_prefix_returns_itself() {
1226 let svc = SubstrateService::new();
1227 svc.publish(None, "substrate/x", serde_json::json!(1));
1228 let snap = svc.list(None, "substrate/x", 1).unwrap();
1229 assert_eq!(snap.children.len(), 1);
1230 assert_eq!(snap.children[0].path, "substrate/x");
1231 assert!(snap.children[0].has_value);
1232 assert_eq!(snap.children[0].child_count, 0);
1233 }
1234
1235 #[test]
1236 fn list_empty_prefix_lists_from_root() {
1237 let svc = SubstrateService::new();
1238 seed_sensor_substrate(&svc);
1239 let snap = svc.list(None, "", 1).unwrap();
1240 let paths: Vec<&str> = snap.children.iter().map(|c| c.path.as_str()).collect();
1241 assert_eq!(paths, vec!["substrate"]);
1242 let snap2 = svc.list(None, "/", 1).unwrap();
1244 assert_eq!(snap.children, snap2.children);
1245 }
1246
1247 #[test]
1248 fn list_trailing_slash_is_normalized() {
1249 let svc = SubstrateService::new();
1250 seed_sensor_substrate(&svc);
1251 let a = svc.list(None, "substrate/sensor", 1).unwrap();
1252 let b = svc.list(None, "substrate/sensor/", 1).unwrap();
1253 assert_eq!(a.children, b.children);
1254 }
1255
1256 #[test]
1257 fn list_skips_paths_without_value_from_notify_only() {
1258 let svc = SubstrateService::new();
1259 svc.notify(None, "substrate/ghost/path");
1262 let snap = svc.list(None, "substrate", 1).unwrap();
1263 assert!(
1264 snap.children.is_empty(),
1265 "ghost entry leaked: {:?}",
1266 snap.children
1267 );
1268 }
1269
1270 #[test]
1271 fn list_tick_reflects_global_tick() {
1272 let svc = SubstrateService::new();
1273 svc.publish(None, "substrate/a", serde_json::json!(1));
1274 svc.publish(None, "substrate/b", serde_json::json!(2));
1275 let snap = svc.list(None, "substrate", 1).unwrap();
1276 assert!(snap.tick >= 2, "tick should advance with writes");
1277 }
1278
1279 #[test]
1280 fn list_egress_denies_anonymous_on_capture_prefix() {
1281 let svc = SubstrateService::new();
1282 svc.declare("substrate/mic/capture", Sensitivity::Capture);
1283 svc.publish(
1284 Some("aid-1"),
1285 "substrate/mic/capture",
1286 serde_json::json!({"pcm": []}),
1287 );
1288 let err = svc.list(None, "substrate/mic/capture", 1).unwrap_err();
1289 assert!(err.reason.contains("requires authenticated"));
1290 }
1291
1292 #[test]
1293 fn list_hides_capture_children_from_anonymous() {
1294 let svc = SubstrateService::new();
1295 svc.publish(
1297 None,
1298 "substrate/mic/public",
1299 serde_json::json!({"ok": true}),
1300 );
1301 svc.declare("substrate/mic/capture", Sensitivity::Capture);
1303 svc.publish(
1304 Some("aid"),
1305 "substrate/mic/capture",
1306 serde_json::json!({"pcm": []}),
1307 );
1308
1309 let snap = svc.list(None, "substrate/mic", 1).unwrap();
1310 let paths: Vec<&str> = snap.children.iter().map(|c| c.path.as_str()).collect();
1311 assert!(paths.contains(&"substrate/mic/public"));
1312 assert!(
1313 !paths.contains(&"substrate/mic/capture"),
1314 "capture-tier child leaked to anonymous caller: {paths:?}"
1315 );
1316
1317 let snap = svc.list(Some("aid"), "substrate/mic", 1).unwrap();
1319 let paths: Vec<&str> = snap.children.iter().map(|c| c.path.as_str()).collect();
1320 assert!(paths.contains(&"substrate/mic/capture"));
1321 }
1322
1323 #[test]
1324 fn list_sort_order_is_stable_ascii() {
1325 let svc = SubstrateService::new();
1326 svc.publish(None, "substrate/z", serde_json::json!(1));
1327 svc.publish(None, "substrate/a", serde_json::json!(2));
1328 svc.publish(None, "substrate/m", serde_json::json!(3));
1329 let snap = svc.list(None, "substrate", 1).unwrap();
1330 let paths: Vec<&str> = snap.children.iter().map(|c| c.path.as_str()).collect();
1331 assert_eq!(paths, vec!["substrate/a", "substrate/m", "substrate/z"]);
1332 }
1333
1334 #[test]
1337 fn publish_gated_accepts_path_under_node_prefix() {
1338 let svc = SubstrateService::new();
1339 let tick = svc
1340 .publish_gated(Some("n1"), "substrate/n1/sensor/mic", serde_json::json!(42))
1341 .expect("valid prefix accepted");
1342 assert_eq!(tick, 1);
1343 let snap = svc
1345 .read(Some("node:n1"), "substrate/n1/sensor/mic")
1346 .unwrap();
1347 assert_eq!(snap.value, Some(serde_json::json!(42)));
1348 }
1349
1350 #[test]
1351 fn publish_gated_rejects_cross_node_write() {
1352 let svc = SubstrateService::new();
1353 let err = svc
1354 .publish_gated(Some("n1"), "substrate/n2/sensor/mic", serde_json::json!(0))
1355 .expect_err("cross-node write must be rejected");
1356 match err {
1357 GateDenied::WrongPrefix { path, node_id } => {
1358 assert_eq!(path, "substrate/n2/sensor/mic");
1359 assert_eq!(node_id, "n1");
1360 }
1361 other => panic!("expected WrongPrefix, got {other:?}"),
1362 }
1363 }
1364
1365 #[test]
1366 fn publish_gated_rejects_top_level_write() {
1367 let svc = SubstrateService::new();
1368 let err = svc
1369 .publish_gated(Some("n1"), "substrate/sensor/mic", serde_json::json!(0))
1370 .expect_err("top-level write must be rejected");
1371 assert!(matches!(err, GateDenied::WrongPrefix { .. }));
1372 }
1373
1374 #[test]
1375 fn publish_gated_rejects_missing_node_id() {
1376 let svc = SubstrateService::new();
1377 let err = svc
1378 .publish_gated(None, "substrate/n1/x", serde_json::json!(0))
1379 .expect_err("unsigned writes must be rejected");
1380 match err {
1381 GateDenied::MissingNodeId { path } => {
1382 assert_eq!(path, "substrate/n1/x");
1383 }
1384 other => panic!("expected MissingNodeId, got {other:?}"),
1385 }
1386 }
1387
1388 #[test]
1389 fn publish_gated_rejects_prefix_collision() {
1390 let svc = SubstrateService::new();
1394 let err = svc
1395 .publish_gated(Some("n1"), "substrate/n11/x", serde_json::json!(0))
1396 .expect_err("sibling-prefix collision must be rejected");
1397 assert!(matches!(err, GateDenied::WrongPrefix { .. }));
1398 }
1399
1400 #[test]
1401 fn publish_gated_increments_tick_like_publish() {
1402 let svc = SubstrateService::new();
1403 let t1 = svc
1404 .publish_gated(Some("n1"), "substrate/n1/a", serde_json::json!(1))
1405 .unwrap();
1406 let t2 = svc
1407 .publish_gated(Some("n1"), "substrate/n1/b", serde_json::json!(2))
1408 .unwrap();
1409 assert!(t2 > t1);
1410 }
1411
1412 #[test]
1413 fn publish_gated_fans_out_to_subscribers() {
1414 let svc = SubstrateService::new();
1415 let (_id, mut rx) = svc
1417 .subscribe(Some("node:n1"), "substrate/n1/sensor/mic")
1418 .unwrap();
1419 svc.publish_gated(
1420 Some("n1"),
1421 "substrate/n1/sensor/mic",
1422 serde_json::json!({"rms_db": -40}),
1423 )
1424 .unwrap();
1425 let rt = tokio::runtime::Builder::new_current_thread()
1426 .enable_all()
1427 .build()
1428 .unwrap();
1429 let got = rt.block_on(async move { rx.recv().await });
1430 assert!(got.is_some(), "subscriber did not receive fanout");
1431 }
1432
1433 #[test]
1436 fn gate_accepts_derived_write_with_grant() {
1437 let svc = SubstrateService::new();
1438 let registry = crate::NodeRegistry::new();
1439 registry
1440 .issue_derived_grant("n-daemon", "transcript", crate::GrantScope::TopicPrefix)
1441 .unwrap();
1442 let tick = svc
1443 .publish_gated_with_grants(
1444 Some("n-daemon"),
1445 "substrate/_derived/transcript/n-foo/mic",
1446 serde_json::json!({"text": "hi"}),
1447 ®istry,
1448 )
1449 .expect("grant in place — write must succeed");
1450 assert_eq!(tick, 1);
1451 let snap = svc
1454 .read(None, "substrate/_derived/transcript/n-foo/mic")
1455 .unwrap();
1456 assert_eq!(snap.value, Some(serde_json::json!({"text": "hi"})));
1457 }
1458
1459 #[test]
1460 fn gate_rejects_derived_write_without_grant() {
1461 let svc = SubstrateService::new();
1462 let registry = crate::NodeRegistry::new();
1463 let err = svc
1465 .publish_gated_with_grants(
1466 Some("n-daemon"),
1467 "substrate/_derived/transcript/n-foo/mic",
1468 serde_json::json!({"text": "nope"}),
1469 ®istry,
1470 )
1471 .expect_err("missing grant must reject");
1472 match err {
1473 GateDenied::MissingDerivedGrant { path, node_id } => {
1474 assert_eq!(path, "substrate/_derived/transcript/n-foo/mic");
1475 assert_eq!(node_id, "n-daemon");
1476 }
1477 other => panic!("expected MissingDerivedGrant, got {other:?}"),
1478 }
1479 }
1480
1481 #[test]
1482 fn gate_rejects_derived_for_unrelated_topic() {
1483 let svc = SubstrateService::new();
1484 let registry = crate::NodeRegistry::new();
1485 registry
1489 .issue_derived_grant("n-daemon", "transcript", crate::GrantScope::TopicPrefix)
1490 .unwrap();
1491 let err = svc
1492 .publish_gated_with_grants(
1493 Some("n-daemon"),
1494 "substrate/_derived/classify/n-foo/mic",
1495 serde_json::json!({"label": "speech"}),
1496 ®istry,
1497 )
1498 .expect_err("grant scoped to other topic must not apply");
1499 assert!(matches!(err, GateDenied::MissingDerivedGrant { .. }));
1500 }
1501
1502 #[test]
1503 fn gate_accepts_node_prefix_write_unchanged() {
1504 let svc = SubstrateService::new();
1509 let registry = crate::NodeRegistry::new();
1510 let tick = svc
1511 .publish_gated_with_grants(
1512 Some("n-daemon"),
1513 "substrate/n-daemon/sensor/mic",
1514 serde_json::json!(42),
1515 ®istry,
1516 )
1517 .expect("node-private path still accepted");
1518 assert_eq!(tick, 1);
1519 }
1520
1521 #[test]
1522 fn gate_rejects_cross_node_write_unchanged() {
1523 let svc = SubstrateService::new();
1526 let registry = crate::NodeRegistry::new();
1527 let err = svc
1528 .publish_gated_with_grants(
1529 Some("n-a"),
1530 "substrate/n-b/sensor/mic",
1531 serde_json::json!(0),
1532 ®istry,
1533 )
1534 .expect_err("cross-node write must be rejected");
1535 match err {
1536 GateDenied::WrongPrefix { path, node_id } => {
1537 assert_eq!(path, "substrate/n-b/sensor/mic");
1538 assert_eq!(node_id, "n-a");
1539 }
1540 other => panic!("expected WrongPrefix, got {other:?}"),
1541 }
1542 }
1543
1544 #[test]
1545 fn gate_rejects_top_level_flat_write_unchanged() {
1546 let svc = SubstrateService::new();
1551 let registry = crate::NodeRegistry::new();
1552 registry
1553 .issue_derived_grant("n-daemon", "transcript", crate::GrantScope::TopicPrefix)
1554 .unwrap();
1555 let err = svc
1556 .publish_gated_with_grants(
1557 Some("n-daemon"),
1558 "substrate/sensor/mic",
1559 serde_json::json!(0),
1560 ®istry,
1561 )
1562 .expect_err("flat path must be rejected");
1563 assert!(matches!(err, GateDenied::WrongPrefix { .. }));
1564 }
1565
1566 #[test]
1567 fn legacy_publish_gated_still_rejects_derived_paths() {
1568 let svc = SubstrateService::new();
1573 let err = svc
1574 .publish_gated(
1575 Some("n-daemon"),
1576 "substrate/_derived/transcript/n-foo/mic",
1577 serde_json::json!({"text": "x"}),
1578 )
1579 .expect_err("legacy gate must reject derived writes");
1580 assert!(matches!(err, GateDenied::WrongPrefix { .. }));
1581 }
1582}