Skip to main content

clawft_kernel/
substrate_service.rs

1//! Kernel-side substrate service.
2//!
3//! Provides a minimal, daemon-hosted implementation of the four
4//! substrate RPCs (`substrate.read`, `substrate.publish`,
5//! `substrate.subscribe`, `substrate.notify`). Each path carries:
6//!
7//! - a most-recent `serde_json::Value` (the state),
8//! - a monotonic tick that advances on every write,
9//! - a declared [`crate::topic::SubscriberSink`] set for fan-out,
10//! - a declared [`Sensitivity`] (Public / Workspace / Private /
11//!   Capture) consulted by the policy gate.
12//!
13//! The service is intentionally in-memory. It replaces the ad-hoc
14//! markdown + file-backed adapter hacks described in weftos-0.7 by
15//! giving external clients a real pub/sub+read/write surface to the
16//! kernel's substrate.
17//!
18//! # Egress gating
19//!
20//! All reads/subscribes pass through [`SubstrateService::egress_check`]
21//! which enforces:
22//!
23//! 1. **ADR-057 per-path ACLs** — deny-by-default for `sensor/**` and
24//!    actor private subtrees; public for cluster/health/meta/chain.
25//! 2. **Sensitivity tier** — `Capture` still requires an authenticated
26//!    caller (bring-up residual until grants land).
27//!
28//! ACL rejections surface as [`EgressDenied::acl_denied`] with a wire
29//! message starting with `acl_denied:` so clients can distinguish them
30//! from path-not-found. Authenticated denials are recorded for ExoChain
31//! emission (`substrate.read.denied`).
32
33use 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/// Privacy sensitivity of a substrate path.
50///
51/// Mirrors `clawft_substrate::Sensitivity` intentionally so adapter
52/// code can share the classification vocabulary. Kept local here to
53/// avoid a kernel → substrate crate dependency.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
55#[serde(rename_all = "lowercase")]
56pub enum Sensitivity {
57    /// Safe to display anywhere; no prompt required.
58    #[default]
59    Public,
60    /// Scoped to the current workspace/project.
61    Workspace,
62    /// Personal data beyond the workspace.
63    Private,
64    /// Derived from ambient capture (camera/mic/screen).
65    Capture,
66}
67
68impl Sensitivity {
69    /// Whether this level requires an authenticated caller for
70    /// read / subscribe operations. `Capture` (and higher, if ever
71    /// added) always requires; the rest allow anonymous reads for
72    /// bring-up.
73    pub fn requires_caller_identity(self) -> bool {
74        matches!(self, Sensitivity::Capture)
75    }
76
77    /// Short text label, for log lines and error messages.
78    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
88/// One substrate path's state.
89struct 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/// Snapshot of a substrate read.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct SubstrateReadSnapshot {
110    /// Current value at the path; `None` if never written.
111    pub value: Option<Value>,
112    /// Monotonic tick for the path.
113    pub tick: u64,
114    /// Declared sensitivity.
115    pub sensitivity: Sensitivity,
116}
117
118/// One child entry returned by [`SubstrateService::list`].
119///
120/// Only paths that have (or have ever had) a Replace value are
121/// considered "present" for the purpose of `has_value`. `child_count`
122/// is the number of descendants strictly below `path` that themselves
123/// have a value — internal nodes without values do not inflate it.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct SubstrateListEntry {
126    /// Full substrate path of this child (no trailing slash).
127    pub path: String,
128    /// `true` if this exact path has been published.
129    pub has_value: bool,
130    /// Count of descendants below `path` that themselves carry a value.
131    pub child_count: u32,
132}
133
134/// Snapshot returned by [`SubstrateService::list`].
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct SubstrateListSnapshot {
137    /// Children enumerated under the requested prefix (sorted by path).
138    pub children: Vec<SubstrateListEntry>,
139    /// Global substrate tick at the moment the list was taken. Mirrors
140    /// [`SubstrateReadSnapshot::tick`] semantics — monotonic.
141    pub tick: u64,
142}
143
144/// Reason an egress check denied access.
145#[derive(Debug, Clone)]
146pub struct EgressDenied {
147    /// Short failure reason.
148    pub reason: String,
149    /// When set, this is an ADR-057 ACL denial (wire code `acl_denied`).
150    pub acl: Option<AclDenied>,
151}
152
153impl EgressDenied {
154    /// Construct a sensitivity / legacy egress denial.
155    pub fn sensitivity(reason: impl Into<String>) -> Self {
156        Self {
157            reason: reason.into(),
158            acl: None,
159        }
160    }
161
162    /// Construct an ADR-057 ACL denial.
163    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    /// Whether this denial is a path-ACL rejection (vs sensitivity).
172    pub fn is_acl_denied(&self) -> bool {
173        self.acl.is_some() || self.reason.starts_with(AclDenied::CODE)
174    }
175
176    /// Wire error string for the IPC envelope. ACL denials keep the
177    /// `acl_denied:` prefix so clients can branch without parsing prose.
178    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/// Buffered `substrate.read.denied` event awaiting ExoChain append.
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct AclDenialEvent {
204    /// Event kind (always [`READ_DENIED_EVENT`]).
205    pub kind: String,
206    /// Chain payload.
207    pub payload: Value,
208}
209
210/// Reason the node-identity write gate rejected a publish.
211///
212/// The gate enforces that every substrate write lands under
213/// `substrate/<node-id>/` — see
214/// [`crate::node_registry::required_path_prefix`]. Either the path
215/// lies outside the caller's namespace or the caller supplied no node
216/// identity at all.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub enum GateDenied {
219    /// The path does not sit under `substrate/<node-id>/`.
220    WrongPrefix {
221        /// Attempted path.
222        path: String,
223        /// Node that tried to write it.
224        node_id: String,
225    },
226    /// The publish has no declared node identity.
227    MissingNodeId {
228        /// Attempted path.
229        path: String,
230    },
231    /// The path is mesh-canonical (`substrate/_derived/...`) but the
232    /// publishing node holds no [`crate::DerivedWriteGrant`] covering
233    /// it. See `.planning/sensors/PIPELINE-PRIMITIVE-JOURNAL.md` §R3.6.
234    MissingDerivedGrant {
235        /// Attempted path.
236        path: String,
237        /// Node that tried to write it.
238        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/// Substrate RPC service.
265#[derive(Clone)]
266pub struct SubstrateService {
267    inner: Arc<SubstrateInner>,
268}
269
270struct SubstrateInner {
271    entries: DashMap<String, Entry>,
272    global_tick: AtomicU64,
273    /// ADR-057 per-path read ACL table.
274    acl: Mutex<AclTable>,
275    /// Authenticated ACL denials buffered for ExoChain emission.
276    denial_log: Mutex<Vec<AclDenialEvent>>,
277}
278
279impl Default for SubstrateService {
280    fn default() -> Self {
281        Self::new()
282    }
283}
284
285impl SubstrateService {
286    /// Create a new service with ADR-057 boot-default ACLs for the
287    /// local mesh id `"local"`. Prefer [`Self::with_mesh_id`] when the
288    /// real mesh id is known at construction.
289    pub fn new() -> Self {
290        Self::with_mesh_id("local")
291    }
292
293    /// Create a service seeded with ADR-057 defaults for `mesh_id`.
294    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    /// Replace the ACL table (tests / mesh re-bootstrap).
306    pub fn set_acl_table(&self, table: AclTable) {
307        *self.inner.acl.lock().expect("acl mutex") = table;
308    }
309
310    /// Opt a path into public readability (ADR-057 `publish_public`).
311    ///
312    /// Seals `allow: ["public"]` on the exact path and publishes
313    /// `value` under the legacy ungated publish path. Prefer the
314    /// signed [`Self::publish_gated`] flow for production node writes;
315    /// this helper is the ergonomic opt-out of default-private sensor
316    /// posture after a successful gated publish.
317    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    /// Drain buffered ACL denial events (for ExoChain append by the
335    /// daemon dispatch loop). Authenticated denials only.
336    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    /// Record an authenticated ACL denial for later chain emission.
341    fn record_acl_denial(&self, denied: &AclDenied) {
342        // ADR-057: chain-log authenticated denials; rate-limit-summarize
343        // anonymous (we simply skip anonymous here).
344        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    /// Declare (or re-declare) the sensitivity level for a path.
358    ///
359    /// If the path already exists, the declaration is updated in
360    /// place without clearing the value. Paths not declared default
361    /// to [`Sensitivity::Workspace`] on first touch.
362    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    /// Daemon-internal existence check: has anything been published at or
372    /// under `prefix`? Acts as no principal and reads no values, so callers
373    /// (discovery, health) need not impersonate a node to ask. It is not an
374    /// egress path and must not be exposed over RPC.
375    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    /// Egress gate: ADR-057 path ACL then sensitivity tier.
385    ///
386    /// This is intentionally the *one* seam the policy will gate, so
387    /// callers need not change when grants land for Capture.
388    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        // 1. Per-path ACL (ADR-057).
397        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        // 2. Sensitivity tier (Capture still requires identity).
411        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    /// Read the current value + metadata at a path.
445    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    /// Enumerate children of a prefix up to `depth` levels below it.
467    ///
468    /// Contract (ADR-Explorer Phase 1 §3.1):
469    ///
470    /// - `prefix` — substrate path; empty string or `"/"` means
471    ///   "top-level". Trailing slashes are trimmed.
472    /// - `depth = 0` — return only the prefix node itself, if it is
473    ///   a value (no children enumerated).
474    /// - `depth = 1` — one level below `prefix`. Default for the
475    ///   Explorer lazy-tree walk.
476    /// - `depth = N > 1` — up to N levels below `prefix` (flat list,
477    ///   deepest-child first in sort order).
478    ///
479    /// Only paths with a published Replace value participate — pure
480    /// internal nodes (no value of their own but with descendants that
481    /// do) are still surfaced as entries with `has_value: false` when
482    /// they sit on the path down to a value-bearing leaf; see
483    /// `child_count` semantics on [`SubstrateListEntry`].
484    ///
485    /// Per-child egress: the prefix itself is `egress_check`'d once
486    /// under op `"list"`. Each candidate descendant path is then also
487    /// egress-checked so capture-tier path *names* don't leak to an
488    /// anonymous caller — matching the spirit of `read`'s per-path gate.
489    ///
490    /// Not a hot path (the Explorer expands one prefix per user click);
491    /// a full DashMap scan is fine for the foreseeable substrate size.
492    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        // Gate once on the prefix itself so a `list` on a capture-tier
500        // node requires the same identity `read` would.
501        self.egress_check(caller, &norm_prefix, "list")?;
502
503        let global_tick = self.inner.global_tick.load(Ordering::Relaxed);
504
505        // Collect all path + (has_value, sensitivity) pairs once. Cheap
506        // relative to the Explorer click cadence; avoids holding the
507        // DashMap iterator across per-entry work.
508        let mut value_paths: Vec<(String, Sensitivity)> =
509            Vec::with_capacity(self.inner.entries.len());
510        for r in self.inner.entries.iter() {
511            // Only paths that have been published participate. An
512            // entry with `value == None` may still exist from a bare
513            // `notify` (which creates the entry without a value) or
514            // from `declare`; those must NOT surface in listings.
515            if r.value.is_some() {
516                value_paths.push((r.key().clone(), r.sensitivity));
517            }
518        }
519
520        // Depth 0 — just the exact-match node.
521        if depth == 0 {
522            if let Some((_, sens)) = value_paths.iter().find(|(p, _)| *p == norm_prefix) {
523                // Respect capture-tier + ACL on the entry itself.
524                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 the prefix itself is a leaf value (e.g. list of
544        // `substrate/sensor/mic` when that path carries the mic
545        // snapshot directly), return it as a single entry so the caller
546        // can render the leaf even when they asked for its subtree.
547        if norm_prefix.is_empty() {
548            // Root list: proceed to group-by-next-segment below.
549        } 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        // Group descendants by (prefix + next N segments for N in 1..=depth),
565        // collecting one entry per unique child path. Using a BTreeMap keeps
566        // results sorted (ASCII) without a post-sort pass.
567        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            // Only consider strict descendants of the prefix (or, for
578            // empty prefix, all paths).
579            let Some(rest) = strict_descendant_rest(path, &norm_prefix) else {
580                continue;
581            };
582            // Split the relative tail into segments.
583            let tail_segs: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect();
584            if tail_segs.is_empty() {
585                continue;
586            }
587            // For each level from 1..=min(depth, tail_segs.len()) compute
588            // the ancestor path at that level. Record whether that
589            // exact ancestor carries a value.
590            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                // Hide intermediate ancestors the caller cannot list.
594                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    /// Publish a Replace delta under the node-identity write gate.
621    ///
622    /// Every node-private substrate write belongs to exactly one
623    /// **node**, and may only land under that node's namespace —
624    /// `substrate/<node-id>/…`. This is the enforcement seam: callers
625    /// provide the `node_id` they have already authenticated
626    /// (signature verified at the RPC boundary, or trusted at
627    /// in-process call sites where the daemon stamps its own id);
628    /// `publish_gated` refuses to write anywhere else.
629    ///
630    /// Mesh-canonical writes (`substrate/_derived/...`) are
631    /// **rejected** by this method — the legacy gate has no
632    /// [`crate::NodeRegistry`] handle and cannot consult the
633    /// `DerivedWriteGrant` table. Use
634    /// [`Self::publish_gated_with_grants`] for that tier.
635    ///
636    /// Returns the new tick on success, or [`GateDenied`] when the
637    /// path is outside the node's prefix or no node id was supplied.
638    /// See [`crate::node_registry::path_belongs_to`] for the exact
639    /// rule.
640    ///
641    /// Fans out to all external-stream subscribers registered via
642    /// [`Self::subscribe`].
643    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    /// Tier-aware publish gate.
662    ///
663    /// Splits writes by path tier per
664    /// `.planning/sensors/PIPELINE-PRIMITIVE-JOURNAL.md` §R3.3:
665    ///
666    /// - **Node-private** (`substrate/<node-id>/...`) — strict prefix
667    ///   match, identical to [`Self::publish_gated`].
668    /// - **Mesh-canonical** (`substrate/_derived/...`) — checks the
669    ///   registry's [`crate::DerivedWriteGrant`] table.
670    ///   `node_registry.has_derived_grant(node_id, path)` must return
671    ///   `true`. Without a grant the write is rejected with
672    ///   [`GateDenied::MissingDerivedGrant`].
673    ///
674    /// The dual-tier check is the load-bearing bit of R3 — it lets a
675    /// daemon-class node publish whisper transcripts at
676    /// `substrate/_derived/transcript/<source>/mic` while still
677    /// honouring the per-node prefix rule for everything else.
678    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        // Tier detection. Mesh-canonical paths get the grant check;
689        // anything else falls through to the legacy per-node-prefix
690        // rule. Note that `_derived/` is the *only* reserved word
691        // under `substrate/` — node-ids are 32 hex chars, so they
692        // cannot collide with this segment (see
693        // `node_id::node_id_from_pubkey`).
694        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    /// Shared post-gate write path. Allocates a new tick, writes the
713    /// value, and fans out to all subscribers. Both
714    /// [`Self::publish_gated`] and
715    /// [`Self::publish_gated_with_grants`] flow through here so the
716    /// fan-out behaviour stays identical across tiers.
717    ///
718    /// `node_id` is recorded as the caller on the fan-out line so
719    /// subscribers can audit who wrote it. We deliberately keep the
720    /// existing `caller` wire-field name — semantics evolve (from
721    /// "actor" to "node") without breaking the log shape.
722    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    /// Publish a Replace delta at a path. Returns the new tick.
737    ///
738    /// Fans out to all external-stream subscribers registered via
739    /// [`Self::subscribe`]. For bring-up, the owner-check is trusted
740    /// — the daemon layer should gate on its own agent role policy.
741    ///
742    /// **Legacy path.** This is the pre-node-identity surface. New
743    /// callers should use [`Self::publish_gated`] so the per-node
744    /// prefix invariant holds. Phased migration lets existing in-
745    /// process publishers keep working until they're each updated.
746    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    /// Emit a notify-only signal (no payload change) on a path.
762    /// Returns the current tick (unchanged if no write has happened).
763    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    /// Subscribe an external streaming sink to updates on a path.
778    ///
779    /// Returns the subscriber id and a channel receiver. The caller
780    /// pipes the JSON lines the sink receives into their socket.
781    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    /// Remove a specific subscription. Safe if the id is unknown.
799    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    /// Number of declared paths (for metrics / tests).
806    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                    // closed or full — prune closed ones
818                    if tx.is_closed() {
819                        dead.push(*id);
820                    }
821                }
822            }
823            SubscriberSink::PidInbox(_) => {
824                // PID delivery isn't wired for substrate today.
825                // The kernel a2a router handles in-process fanout.
826            }
827        }
828    }
829    if !dead.is_empty() {
830        sinks.retain(|(id, _)| !dead.contains(id));
831    }
832}
833
834/// Normalize a user-supplied prefix: drop leading/trailing slashes.
835/// An empty-or-`"/"` prefix becomes `""`, meaning "list from the root".
836fn normalize_prefix(prefix: &str) -> String {
837    let trimmed = prefix.trim_matches('/');
838    trimmed.to_string()
839}
840
841/// Return the relative remainder after `prefix/` inside `path`, if
842/// `path` is a strict descendant of `prefix`. Handles the root case
843/// (empty prefix means "every path is a descendant").
844fn strict_descendant_rest<'a>(path: &'a str, prefix: &str) -> Option<&'a str> {
845    if prefix.is_empty() {
846        // Root prefix: every non-empty path is a descendant; rest is
847        // the whole path.
848        if path.is_empty() {
849            return None;
850        }
851        return Some(path);
852    }
853    if path == prefix {
854        return None; // same node, not a strict descendant
855    }
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
861/// Join `prefix` + segment slice into a substrate path. Root-aware:
862/// empty prefix yields just the joined segments.
863fn 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
879/// Whether a path is a leaf in the current substrate: no other
880/// value-carrying path is a strict descendant of it.
881fn 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
887/// Count all descendants of `path` that carry a value and are visible
888/// to `caller` under the egress + ACL rules.
889fn 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
910/// Mirror of sensitivity half of [`SubstrateService::egress_check`] for
911/// a path whose sensitivity we already know. Used by `list` so we can
912/// skip capture-tier children for anonymous callers without taking a
913/// second DashMap lookup per entry. ACL checks are applied separately
914/// via [`list_path_visible`].
915fn egress_permits(caller: Option<&str>, sensitivity: Sensitivity) -> bool {
916    !(sensitivity.requires_caller_identity() && caller.is_none())
917}
918
919/// Whether `path` is visible to `caller` under the service ACL table.
920fn 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    // ── ADR-057 path ACLs ───────────────────────────────────────────
1008
1009    #[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        // Authenticated denial is buffered for ExoChain.
1024        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        // ADR-057 MUST-HAVE integration: subscriber sees meta, not mic.
1047        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    // ── list() ──────────────────────────────────────────────────────
1115
1116    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        // Expect `substrate/kernel` and `substrate/sensor` (both internal).
1160        let paths: Vec<&str> = snap.children.iter().map(|c| c.path.as_str()).collect();
1161        assert_eq!(paths, vec!["substrate/kernel", "substrate/sensor"]);
1162        // Neither is itself a published value — internal nodes.
1163        assert!(snap.children.iter().all(|c| !c.has_value));
1164        // Child counts reflect descendants-with-values.
1165        let sensor = snap
1166            .children
1167            .iter()
1168            .find(|c| c.path == "substrate/sensor")
1169            .unwrap();
1170        // substrate/sensor/mic, substrate/sensor/mic/history, substrate/sensor/tof
1171        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        // mic has one descendant (history); tof has none.
1192        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        // depth=2 reveals mic/history as a sibling entry in the flat list.
1203        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        // A value-carrying prefix: returns a single entry for itself.
1213        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        // A non-value prefix at depth 0: no entry.
1220        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        // root prefix, slash-only — equivalent.
1243        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        // `notify` creates an entry without `value`. It must NOT
1260        // appear in list output.
1261        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        // Parent is workspace — safe to list.
1296        svc.publish(
1297            None,
1298            "substrate/mic/public",
1299            serde_json::json!({"ok": true}),
1300        );
1301        // Capture-tier child.
1302        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        // With identity, the capture child is visible.
1318        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    // ── node-identity write gate ───────────────────────────────
1335
1336    #[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        // Sensor path is private (ADR-057) — owning node may read it back.
1344        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        // `n1` must not be able to write into `n11`'s namespace just
1391        // because the string starts with `substrate/n1`. The trailing
1392        // slash in `required_path_prefix` blocks it.
1393        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        // ADR-057: sensor/** requires owning node (or scope:admin).
1416        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    // ── publish_gated_with_grants (R3.3 tier-aware gate) ───────────
1434
1435    #[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                &registry,
1448            )
1449            .expect("grant in place — write must succeed");
1450        assert_eq!(tick, 1);
1451        // Read back through the egress gate to prove the value
1452        // landed.
1453        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        // No grant issued.
1464        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                &registry,
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        // Grant only for `transcript`; daemon attempts a `classify`
1486        // write. R3.6: grants are bounded — one pipeline's grant
1487        // does NOT bleed into another's namespace.
1488        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                &registry,
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        // Regression: tier detection must not break the node-private
1505        // happy path. With no grant in the registry, a write under
1506        // the node's own prefix still succeeds via the fall-through
1507        // branch.
1508        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                &registry,
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        // Regression: the per-node prefix rule still rejects writes
1524        // into another node's subtree in the new gate.
1525        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                &registry,
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        // Regression: a path with no node-id segment (the old "flat"
1547        // shape) still gets rejected as WrongPrefix even with a grant
1548        // in the registry — it's neither node-private nor
1549        // mesh-canonical.
1550        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                &registry,
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        // The legacy `publish_gated` (no registry) must reject
1569        // `_derived/` writes outright — it has no way to consult
1570        // the grant table. This guards against accidental
1571        // regressions in callers that haven't migrated.
1572        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}