Skip to main content

clawft_kernel/
stream_anchor.rs

1//! Stream-window chain anchor.
2//!
3//! Subscribes to configured kernel topics via the topic router, runs
4//! a rolling BLAKE3 hash + message / byte counter over a fixed time
5//! window, and on each window close appends a
6//! `stream.window_commit` event to the local chain. This is the
7//! control-plane audit anchor for stream data (audio frames, sensor
8//! batches, etc.) — the data itself stays on the fast path, but the
9//! chain records a tamper-evident summary.
10//!
11//! # Layout
12//!
13//! One [`StreamWindowAnchor`] per anchored topic, wired to the a2a
14//! topic router via [`crate::topic::SubscriberSink::ExternalStream`].
15//! The anchor's consumer task pulls serialized `KernelMessage` JSON
16//! lines off the channel, updates its in-progress window, and fires
17//! a chain event when the configured duration elapses.
18//!
19//! # Feature gating
20//!
21//! Only compiled with the `exochain` feature — without it there is
22//! nothing to anchor to. Gracefully no-ops when the kernel lacks a
23//! `ChainManager`.
24
25use std::sync::Arc;
26use std::time::{Duration, Instant};
27
28use serde_json::Value;
29use tokio::sync::mpsc;
30use tokio_util::sync::CancellationToken;
31use tracing::{debug, info, warn};
32
33use crate::a2a::A2ARouter;
34use crate::chain::ChainManager;
35use crate::topic::{SubscriberId, SubscriberSink};
36
37/// Per-topic window accumulator state.
38#[derive(Debug)]
39struct Window {
40    started_at_ms: u64,
41    first_msg_ms: Option<u64>,
42    last_msg_ms: Option<u64>,
43    message_count: u64,
44    byte_count: u64,
45    hasher: blake3::Hasher,
46}
47
48impl Window {
49    fn new(now_ms: u64) -> Self {
50        Self {
51            started_at_ms: now_ms,
52            first_msg_ms: None,
53            last_msg_ms: None,
54            message_count: 0,
55            byte_count: 0,
56            hasher: blake3::Hasher::new(),
57        }
58    }
59
60    fn record(&mut self, bytes: &[u8], now_ms: u64) {
61        if self.first_msg_ms.is_none() {
62            self.first_msg_ms = Some(now_ms);
63        }
64        self.last_msg_ms = Some(now_ms);
65        self.message_count += 1;
66        self.byte_count += bytes.len() as u64;
67        self.hasher.update(bytes);
68    }
69
70    fn is_empty(&self) -> bool {
71        self.message_count == 0
72    }
73}
74
75/// Per-topic anchor handle; dropping it cancels the consumer task.
76pub struct TopicAnchor {
77    /// Topic name being anchored.
78    pub topic: String,
79    cancel: CancellationToken,
80    subscriber_id: SubscriberId,
81    router: Arc<A2ARouter>,
82}
83
84impl TopicAnchor {
85    /// Cancel the consumer and unsubscribe from the router.
86    pub fn shutdown(self) {
87        self.cancel.cancel();
88        self.router
89            .topic_router()
90            .unsubscribe_id(&self.topic, self.subscriber_id);
91    }
92}
93
94/// Stream window anchor service.
95///
96/// Construct via [`StreamWindowAnchor::start_topic`]. One instance
97/// per topic is cheapest, but the service itself holds no state —
98/// the [`TopicAnchor`] handles carry all the lifetime.
99pub struct StreamWindowAnchor;
100
101impl StreamWindowAnchor {
102    /// Start anchoring a single topic.
103    ///
104    /// Subscribes an [`SubscriberSink::ExternalStream`] sink to the
105    /// topic router, then spawns a consumer task that fires a
106    /// `stream.window_commit` chain event every `window` duration.
107    ///
108    /// Returns the [`TopicAnchor`] handle. Drop it or call
109    /// [`TopicAnchor::shutdown`] to stop.
110    pub fn start_topic(
111        a2a: Arc<A2ARouter>,
112        chain: Option<Arc<ChainManager>>,
113        topic: String,
114        window: Duration,
115    ) -> TopicAnchor {
116        let (tx, rx) = mpsc::channel::<Vec<u8>>(1024);
117        let subscriber_id = a2a
118            .topic_router()
119            .subscribe_sink(&topic, SubscriberSink::ExternalStream(tx));
120
121        let cancel = CancellationToken::new();
122        let child = cancel.child_token();
123        let topic_for_task = topic.clone();
124        tokio::spawn(async move {
125            run_consumer(topic_for_task, window, rx, chain, child).await;
126        });
127
128        info!(topic = %topic, sub_id = subscriber_id.0, window_ms = window.as_millis() as u64, "stream anchor started");
129
130        TopicAnchor {
131            topic,
132            cancel,
133            subscriber_id,
134            router: a2a,
135        }
136    }
137}
138
139async fn run_consumer(
140    topic: String,
141    window: Duration,
142    mut rx: mpsc::Receiver<Vec<u8>>,
143    chain: Option<Arc<ChainManager>>,
144    cancel: CancellationToken,
145) {
146    let started = Instant::now();
147    let now_ms = || chrono::Utc::now().timestamp_millis().max(0) as u64;
148    let mut current = Window::new(now_ms());
149    let mut timer = tokio::time::interval_at(tokio::time::Instant::now() + window, window);
150    timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
151
152    loop {
153        tokio::select! {
154            biased;
155            _ = cancel.cancelled() => {
156                flush_window(&topic, &mut current, chain.as_deref(), now_ms());
157                debug!(topic = %topic, "stream anchor cancelled");
158                return;
159            }
160            msg = rx.recv() => {
161                match msg {
162                    Some(bytes) => current.record(&bytes, now_ms()),
163                    None => {
164                        // sender side dropped — router went away.
165                        flush_window(&topic, &mut current, chain.as_deref(), now_ms());
166                        debug!(topic = %topic, "stream anchor: subscriber channel closed");
167                        return;
168                    }
169                }
170            }
171            _ = timer.tick() => {
172                let now = now_ms();
173                flush_window(&topic, &mut current, chain.as_deref(), now);
174                // Start the next window aligned on the tick.
175                current = Window::new(now);
176                // Stall-guard: if we've been running for hours, log it — otherwise quiet.
177                if started.elapsed() > Duration::from_secs(3600) {
178                    debug!(topic = %topic, "stream anchor: hourly mark");
179                }
180            }
181        }
182    }
183}
184
185fn flush_window(topic: &str, window: &mut Window, chain: Option<&ChainManager>, now_ms: u64) {
186    if window.is_empty() {
187        return;
188    }
189    let hash = window.hasher.finalize();
190    let hash_hex = hex_encode(hash.as_bytes());
191    let payload = build_commit_payload(topic, window, now_ms, &hash_hex);
192
193    if let Some(cm) = chain {
194        cm.append("stream", "stream.window_commit", Some(payload.clone()));
195        debug!(
196            topic,
197            samples = window.message_count,
198            bytes = window.byte_count,
199            hash = %hash_hex,
200            "stream.window_commit appended"
201        );
202    } else {
203        warn!(
204            topic,
205            samples = window.message_count,
206            "chain manager unavailable; window_commit dropped"
207        );
208    }
209}
210
211fn build_commit_payload(topic: &str, window: &Window, window_end_ms: u64, hash_hex: &str) -> Value {
212    serde_json::json!({
213        "topic": topic,
214        "window_start_ms": window.started_at_ms,
215        "window_end_ms": window_end_ms,
216        "first_msg_ms": window.first_msg_ms,
217        "last_msg_ms": window.last_msg_ms,
218        "sample_count": window.message_count,
219        "byte_count": window.byte_count,
220        "chunk_count": window.message_count,
221        "blake3": hash_hex,
222    })
223}
224
225fn hex_encode(bytes: &[u8]) -> String {
226    let mut s = String::with_capacity(bytes.len() * 2);
227    for b in bytes {
228        s.push_str(&format!("{b:02x}"));
229    }
230    s
231}
232
233/// Glob-match a topic name against a configured pattern.
234///
235/// Supports the same trivial pattern vocabulary as the substrate
236/// `TopicDecl`: exact match, or a trailing `.*` / `*` wildcard
237/// matching any suffix. Returns true on match.
238pub fn topic_matches(pattern: &str, topic: &str) -> bool {
239    if pattern == topic {
240        return true;
241    }
242    if let Some(prefix) = pattern.strip_suffix("*") {
243        return topic.starts_with(prefix);
244    }
245    false
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn topic_match_exact() {
254        assert!(topic_matches("sensor.mic", "sensor.mic"));
255        assert!(!topic_matches("sensor.mic", "sensor.cam"));
256    }
257
258    #[test]
259    fn topic_match_trailing_wildcard() {
260        assert!(topic_matches("sensor.*", "sensor.mic"));
261        assert!(topic_matches("sensor.*", "sensor.cam.depth"));
262        assert!(!topic_matches("sensor.*", "mic.left"));
263    }
264
265    #[test]
266    fn window_builds_hash_and_counts() {
267        let mut w = Window::new(10);
268        w.record(b"{\"a\":1}\n", 12);
269        w.record(b"{\"a\":2}\n", 14);
270        assert_eq!(w.message_count, 2);
271        assert_eq!(w.byte_count, 16);
272        let h = w.hasher.finalize();
273        // The hash is non-zero and reproducible.
274        let again = {
275            let mut h2 = blake3::Hasher::new();
276            h2.update(b"{\"a\":1}\n");
277            h2.update(b"{\"a\":2}\n");
278            h2.finalize()
279        };
280        assert_eq!(h.as_bytes(), again.as_bytes());
281    }
282}