Skip to main content

bamboo_engine/
session_messaging.rs

1//! Internal logical-session messaging service.
2//!
3//! This module deliberately has no HTTP concepts. User transports, tools,
4//! actor adapters, the SDK, and runtime coordinators all submit the same typed
5//! envelope addressed by stable [`Session::id`](bamboo_domain::Session).
6
7use std::collections::{HashSet, VecDeque};
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::Arc;
10use std::time::Instant;
11
12use bamboo_agent_core::storage::Storage;
13use bamboo_domain::{
14    ActorSession, Session, SessionActivationDisposition, SessionActivationError,
15    SessionActivationPolicy, SessionActivationPort, SessionInboxError, SessionInboxPort,
16    SessionInboxReceipt, SessionMessageBody, SessionMessageContent, SessionMessageEnvelope,
17    SessionMessageId, SessionMessageKind, SessionMessageSource, SupervisorReference,
18};
19
20/// A followup is one bounded peer instruction, not a context export or file transfer.
21pub const MAX_SUPERVISOR_FOLLOWUP_BYTES: usize = 16 * 1024;
22
23pub const MAX_PEER_MESSAGE_BYTES: usize = 16 * 1024;
24pub const MAX_PEER_TARGETS: usize = 128;
25pub const MAX_PEER_TARGET_RESULT_BYTES: usize = 32 * 1024;
26const MAX_PEER_TARGET_TITLE_BYTES: usize = 256;
27const MAX_PEER_TARGET_STATUS_BYTES: usize = 64;
28const MAX_PEER_TARGET_SCAN: usize = 1024;
29
30/// Logical discovery data only. A target observation is never delivery authority.
31#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
32pub struct SessionPeerTarget {
33    pub session_id: String,
34    pub parent_session_id: Option<String>,
35    pub kind: bamboo_domain::SessionKind,
36    pub title: String,
37    pub status: Option<String>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
41pub struct SessionPeerTargets {
42    pub targets: Vec<SessionPeerTarget>,
43    pub truncated: bool,
44}
45
46struct PeerTreePort<'a>(&'a dyn Storage);
47
48#[async_trait::async_trait]
49impl crate::session_app::child_session::owned_tree::OwnedTreePort for PeerTreePort<'_> {
50    async fn load(
51        &self,
52        id: &str,
53    ) -> Result<Session, crate::session_app::child_session::owned_tree::OwnedTreeError> {
54        use crate::session_app::child_session::owned_tree::OwnedTreeError;
55        bamboo_domain::validate_supervisor_target_id(id)
56            .map_err(|_| OwnedTreeError::InvalidLineage)?;
57        self.0
58            .load_runtime_control_plane(id)
59            .await
60            .map_err(|_| OwnedTreeError::InvalidLineage)?
61            .ok_or(OwnedTreeError::InvalidLineage)
62    }
63
64    async fn child_ids(
65        &self,
66        parent_id: &str,
67    ) -> Result<Vec<String>, crate::session_app::child_session::owned_tree::OwnedTreeError> {
68        use crate::session_app::child_session::owned_tree::OwnedTreeError;
69        self.0
70            .list_child_run_statuses(parent_id)
71            .await
72            .map(|children| children.into_iter().map(|(id, _)| id).collect())
73            .map_err(|_| OwnedTreeError::InvalidLineage)
74    }
75}
76
77/// Observable counters for the internal delivery plane.
78#[derive(Debug, Default)]
79pub struct SessionMessagingMetrics {
80    delivered: AtomicU64,
81    rejected: AtomicU64,
82    invalid_envelope: AtomicU64,
83    unauthorized: AtomicU64,
84    payload_too_large: AtomicU64,
85    backlog_full: AtomicU64,
86    storage_failed: AtomicU64,
87    activation_failed: AtomicU64,
88    active_notified: AtomicU64,
89    activation_reserved: AtomicU64,
90    activation_coalesced: AtomicU64,
91    delivery_latency_micros: AtomicU64,
92}
93
94#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
95pub struct SessionMessagingMetricsSnapshot {
96    pub delivered: u64,
97    pub rejected: u64,
98    pub invalid_envelope: u64,
99    pub unauthorized: u64,
100    pub payload_too_large: u64,
101    pub backlog_full: u64,
102    pub storage_failed: u64,
103    pub activation_failed: u64,
104    pub active_notified: u64,
105    pub activation_reserved: u64,
106    pub activation_coalesced: u64,
107    pub delivery_latency_micros: u64,
108}
109
110impl SessionMessagingMetrics {
111    pub fn snapshot(&self) -> SessionMessagingMetricsSnapshot {
112        SessionMessagingMetricsSnapshot {
113            delivered: self.delivered.load(Ordering::Relaxed),
114            rejected: self.rejected.load(Ordering::Relaxed),
115            invalid_envelope: self.invalid_envelope.load(Ordering::Relaxed),
116            unauthorized: self.unauthorized.load(Ordering::Relaxed),
117            payload_too_large: self.payload_too_large.load(Ordering::Relaxed),
118            backlog_full: self.backlog_full.load(Ordering::Relaxed),
119            storage_failed: self.storage_failed.load(Ordering::Relaxed),
120            activation_failed: self.activation_failed.load(Ordering::Relaxed),
121            active_notified: self.active_notified.load(Ordering::Relaxed),
122            activation_reserved: self.activation_reserved.load(Ordering::Relaxed),
123            activation_coalesced: self.activation_coalesced.load(Ordering::Relaxed),
124            delivery_latency_micros: self.delivery_latency_micros.load(Ordering::Relaxed),
125        }
126    }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct SessionMessengerReceipt {
131    pub delivery: SessionInboxReceipt,
132    pub activation: SessionActivationDisposition,
133}
134
135/// Durable admission separated from activation so coordinators can commit
136/// related control-plane state between those phases without opening a race.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct SessionMessengerAdmission {
139    pub envelope_id: String,
140    pub target_session_id: String,
141    pub delivery: SessionInboxReceipt,
142}
143
144#[derive(Debug, thiserror::Error)]
145pub enum SessionMessengerError {
146    #[error("invalid session message: {0}")]
147    InvalidEnvelope(String),
148    #[error("session message source not found: {0}")]
149    SourceNotFound(String),
150    #[error("session message target not found: {0}")]
151    TargetNotFound(String),
152    #[error("session {source_session_id} is not authorized to message session {target}")]
153    Unauthorized {
154        source_session_id: String,
155        target: String,
156    },
157    #[error(transparent)]
158    Inbox(#[from] SessionInboxError),
159    /// The enqueue is already durable when this error is returned. The receipt
160    /// lets the caller distinguish it from a delivery failure and retry
161    /// activation without inventing a second message id.
162    #[error("message {receipt_id} was durably delivered but activation failed: {source}")]
163    Activation {
164        receipt_id: String,
165        receipt: SessionInboxReceipt,
166        #[source]
167        source: SessionActivationError,
168    },
169    /// Staged delivery is durable, but its explicit activation watermark did
170    /// not persist. Immediate delivery instead binds intent at publication.
171    /// Startup cannot infer permission to run from admission alone; a caller
172    /// must retry this exact envelope until the watermark is committed.
173    #[error(
174        "message {receipt_id} was delivered but activation eligibility was not persisted: {source}"
175    )]
176    ActivationEligibility {
177        receipt_id: String,
178        receipt: SessionInboxReceipt,
179        #[source]
180        source: SessionInboxError,
181    },
182    #[error("session store failure: {0}")]
183    Storage(String),
184}
185
186/// Validates logical identity, atomically enqueues, then requests one runtime
187/// activation. The service owns no process-global registry.
188pub struct SessionMessenger {
189    sessions: Arc<dyn Storage>,
190    inbox: Arc<dyn SessionInboxPort>,
191    activation: Arc<dyn SessionActivationPort>,
192    metrics: Arc<SessionMessagingMetrics>,
193}
194
195impl SessionMessenger {
196    pub fn new(
197        sessions: Arc<dyn Storage>,
198        inbox: Arc<dyn SessionInboxPort>,
199        activation: Arc<dyn SessionActivationPort>,
200    ) -> Self {
201        Self {
202            sessions,
203            inbox,
204            activation,
205            metrics: Arc::new(SessionMessagingMetrics::default()),
206        }
207    }
208
209    pub fn inbox(&self) -> &Arc<dyn SessionInboxPort> {
210        &self.inbox
211    }
212
213    pub fn activation(&self) -> &Arc<dyn SessionActivationPort> {
214        &self.activation
215    }
216
217    pub fn metrics(&self) -> &Arc<SessionMessagingMetrics> {
218        &self.metrics
219    }
220
221    /// Discover bounded logical peers in the caller's current canonical tree.
222    /// `caller_session_id` comes from the authenticated runtime context, never
223    /// from model arguments. Every candidate is revalidated before disclosure.
224    pub async fn list_peer_targets(
225        &self,
226        caller_session_id: &str,
227    ) -> Result<SessionPeerTargets, SessionMessengerError> {
228        let caller = self.peer_member(caller_session_id, true).await?;
229        let mut pending = VecDeque::from([(caller.root_session_id.clone(), None)]);
230        let mut seen = HashSet::new();
231        let mut result = SessionPeerTargets {
232            targets: Vec::new(),
233            truncated: false,
234        };
235        // Account for JSON escaping, separators, and the complete wrapper,
236        // rather than treating raw display-string lengths as an output bound.
237        let mut result_bytes = serde_json::to_vec(&result)
238            .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?
239            .len();
240        while let Some((id, indexed_parent)) = pending.pop_front() {
241            if !seen.insert(id.clone()) {
242                continue;
243            }
244            let current = match self.peer_member(&id, false).await {
245                Ok(current)
246                    if current.root_session_id == caller.root_session_id
247                        && current.project_id_meta() == caller.project_id_meta()
248                        && current.parent_session_id == indexed_parent =>
249                {
250                    current
251                }
252                Ok(_)
253                | Err(SessionMessengerError::TargetNotFound(_))
254                | Err(SessionMessengerError::Unauthorized { .. })
255                | Err(SessionMessengerError::InvalidEnvelope(_)) => continue,
256                Err(error) => return Err(error),
257            };
258            if id != caller_session_id {
259                if result.targets.len() == MAX_PEER_TARGETS {
260                    result.truncated = true;
261                    break;
262                }
263                let target = SessionPeerTarget {
264                    session_id: current.id.clone(),
265                    parent_session_id: current.parent_session_id.clone(),
266                    kind: current.kind,
267                    title: Self::bounded_peer_label(&current.title, MAX_PEER_TARGET_TITLE_BYTES),
268                    status: current.last_run_status().map(|status| {
269                        Self::bounded_peer_label(&status, MAX_PEER_TARGET_STATUS_BYTES)
270                    }),
271                };
272                let target_bytes = serde_json::to_vec(&target)
273                    .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?
274                    .len()
275                    + usize::from(!result.targets.is_empty());
276                if result_bytes.saturating_add(target_bytes) > MAX_PEER_TARGET_RESULT_BYTES {
277                    result.truncated = true;
278                    break;
279                }
280                result_bytes += target_bytes;
281                result.targets.push(target);
282            }
283            let mut children = self
284                .sessions
285                .list_child_run_statuses(&id)
286                .await
287                .map_err(|error| SessionMessengerError::Storage(error.to_string()))?;
288            children.sort_by(|left, right| left.0.cmp(&right.0));
289            let budget = MAX_PEER_TARGET_SCAN.saturating_sub(seen.len() + pending.len());
290            result.truncated |= children.len() > budget;
291            pending.extend(
292                children
293                    .into_iter()
294                    .take(budget)
295                    .map(|(child, _)| (child, Some(id.clone()))),
296            );
297        }
298        Ok(result)
299    }
300
301    /// Send an asynchronous peer instruction. Source identity and association
302    /// fields are constructed by the runtime; callers provide no envelope.
303    /// Reuse the same operation id and message after an uncertain result.
304    pub async fn send_peer(
305        &self,
306        caller_session_id: &str,
307        target_session_id: &str,
308        operation_id: &str,
309        message: &str,
310    ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
311        Self::validate_peer_input(operation_id, message)?;
312        let (caller, target) = self.peer_pair(caller_session_id, target_session_id).await?;
313        let id = Self::peer_message_id(&caller, &target, operation_id);
314        let envelope = Self::peer_envelope(
315            &caller,
316            &target,
317            operation_id,
318            message,
319            id.clone(),
320            id.to_string(),
321            None,
322        );
323        self.send_peer_envelope(envelope, &caller, &target, None)
324            .await
325    }
326
327    /// Reply only to a canonical received peer envelope in the caller's saved
328    /// transcript. Recipient and thread edges are never supplied by the model.
329    pub async fn reply_peer(
330        &self,
331        caller_session_id: &str,
332        received_message_id: &str,
333        operation_id: &str,
334        message: &str,
335    ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
336        Self::validate_peer_input(operation_id, message)?;
337        Self::validate_peer_session_id(caller_session_id)?;
338        let received_id = SessionMessageId::parse(received_message_id)
339            .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
340        let received = self
341            .load_session(caller_session_id)
342            .await?
343            .ok_or_else(|| SessionMessengerError::SourceNotFound(caller_session_id.into()))?;
344        let original = received
345            .messages
346            .iter()
347            .filter(|message| message.id == received_message_id)
348            .find_map(|message| {
349                let envelope: SessionMessageEnvelope = serde_json::from_value(
350                    message.metadata.as_ref()?.get("session_message")?.clone(),
351                )
352                .ok()?;
353                (envelope.id == received_id
354                    && envelope.kind == SessionMessageKind::PeerMessage
355                    && envelope.target_session_id == caller_session_id
356                    && bamboo_domain::is_matching_session_message(message, &envelope))
357                .then_some(envelope)
358            })
359            .ok_or_else(|| {
360                SessionMessengerError::InvalidEnvelope(
361                    "reply requires a canonical received peer message".into(),
362                )
363            })?;
364        let SessionMessageSource::Session {
365            session_id: target_id,
366        } = &original.source
367        else {
368            return Err(SessionMessengerError::InvalidEnvelope(
369                "reply has no logical sender".into(),
370            ));
371        };
372        let (caller, target) = self.peer_pair(caller_session_id, target_id).await?;
373        let thread = original.thread_id.clone().ok_or_else(|| {
374            SessionMessengerError::InvalidEnvelope("reply requires a threaded peer message".into())
375        })?;
376        let original_operation = original.correlation_id.as_deref().ok_or_else(|| {
377            SessionMessengerError::InvalidEnvelope(
378                "reply requires a peer operation identity".into(),
379            )
380        })?;
381        if original.id != Self::peer_message_id(&target, &caller, original_operation) {
382            return Err(SessionMessengerError::InvalidEnvelope(
383                "received peer message belongs to a different session lifetime".into(),
384            ));
385        }
386        SessionMessageId::parse(&thread)
387            .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
388        let envelope = Self::peer_envelope(
389            &caller,
390            &target,
391            operation_id,
392            message,
393            Self::peer_message_id(&caller, &target, operation_id),
394            thread,
395            Some(original.id.clone()),
396        );
397        self.send_peer_envelope(envelope, &caller, &target, Some(&original))
398            .await
399    }
400
401    fn validate_peer_input(operation_id: &str, message: &str) -> Result<(), SessionMessengerError> {
402        SessionMessageId::parse(operation_id)
403            .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
404        if message.trim().is_empty()
405            || message.len() > MAX_PEER_MESSAGE_BYTES
406            || message.contains('\0')
407        {
408            return Err(SessionMessengerError::InvalidEnvelope(format!(
409                "peer message must contain 1..={MAX_PEER_MESSAGE_BYTES} UTF-8 bytes without NUL"
410            )));
411        }
412        Ok(())
413    }
414
415    fn validate_peer_session_id(id: &str) -> Result<(), SessionMessengerError> {
416        bamboo_domain::validate_supervisor_target_id(id)
417            .map_err(|error| SessionMessengerError::InvalidEnvelope(error.into()))?;
418        SessionMessageId::parse(id)
419            .map(|_| ())
420            .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))
421    }
422
423    fn bounded_peer_label(value: &str, max_bytes: usize) -> String {
424        let mut end = value.len().min(max_bytes);
425        while !value.is_char_boundary(end) {
426            end -= 1;
427        }
428        value[..end].to_owned()
429    }
430
431    async fn peer_member(&self, id: &str, source: bool) -> Result<Session, SessionMessengerError> {
432        Self::validate_peer_session_id(id)?;
433        let session = self
434            .sessions
435            .load_runtime_control_plane(id)
436            .await
437            .map_err(|error| SessionMessengerError::Storage(error.to_string()))?
438            .ok_or_else(|| {
439                if source {
440                    SessionMessengerError::SourceNotFound(id.into())
441                } else {
442                    SessionMessengerError::TargetNotFound(id.into())
443                }
444            })?;
445        let unauthorized = || SessionMessengerError::Unauthorized {
446            source_session_id: id.into(),
447            target: session.root_session_id.clone(),
448        };
449        if session.id != id
450            || session
451                .runtime_metadata
452                .as_ref()
453                .and_then(|meta| meta.project_id.as_ref())
454                .zip(session.metadata.get("project_id"))
455                .is_some_and(|(typed, legacy)| typed != legacy)
456            || bamboo_domain::ActorSession::from_session(&session).is_err()
457        {
458            return Err(unauthorized());
459        }
460        if session.kind == bamboo_domain::SessionKind::Root {
461            return Ok(session);
462        }
463        crate::session_app::child_session::owned_tree::load_owned_descendant(
464            &PeerTreePort(self.sessions.as_ref()),
465            &session.root_session_id,
466            id,
467        )
468        .await
469        .map_err(|_| unauthorized())
470    }
471
472    async fn peer_pair(
473        &self,
474        caller_id: &str,
475        target_id: &str,
476    ) -> Result<(Session, Session), SessionMessengerError> {
477        let caller = self.peer_member(caller_id, true).await?;
478        let target = self.peer_member(target_id, false).await?;
479        if caller_id == target_id
480            || caller.root_session_id != target.root_session_id
481            || caller.project_id_meta() != target.project_id_meta()
482        {
483            return Err(SessionMessengerError::Unauthorized {
484                source_session_id: caller_id.into(),
485                target: target_id.into(),
486            });
487        }
488        Ok((caller, target))
489    }
490
491    fn peer_message_id(caller: &Session, target: &Session, operation_id: &str) -> SessionMessageId {
492        SessionMessageId::stable(
493            "session-peer-v1",
494            &serde_json::json!({
495                "source": caller.id, "source_birth": caller.created_at,
496                "target": target.id, "target_birth": target.created_at,
497                "operation_id": operation_id,
498            }),
499        )
500    }
501
502    #[allow(clippy::too_many_arguments)]
503    fn peer_envelope(
504        caller: &Session,
505        target: &Session,
506        operation_id: &str,
507        message: &str,
508        id: SessionMessageId,
509        thread_id: String,
510        in_reply_to: Option<SessionMessageId>,
511    ) -> SessionMessageEnvelope {
512        SessionMessageEnvelope {
513            id,
514            source: SessionMessageSource::Session {
515                session_id: caller.id.clone(),
516            },
517            target_session_id: target.id.clone(),
518            kind: SessionMessageKind::PeerMessage,
519            body: SessionMessageBody::Content(SessionMessageContent::text(message)),
520            created_at: chrono::Utc::now(),
521            thread_id: Some(thread_id),
522            in_reply_to,
523            attempt: None,
524            correlation_id: Some(operation_id.into()),
525        }
526    }
527
528    async fn send_peer_envelope(
529        &self,
530        envelope: SessionMessageEnvelope,
531        caller: &Session,
532        target: &Session,
533        reply_to: Option<&SessionMessageEnvelope>,
534    ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
535        let started = Instant::now();
536        let source = ActorSession::from_session(caller)
537            .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
538        let target = ActorSession::from_session(target)
539            .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
540        // These are the same births used for the stable message id. The inbox
541        // must revalidate them under its retained lifecycle authority; a fresh
542        // relationship read before generic delivery would still race rebirth.
543        let delivery = match self
544            .inbox
545            .deliver_peer_message(&envelope, &source, &target, reply_to)
546            .await
547        {
548            Ok(receipt) => receipt,
549            Err(error) => {
550                let error = SessionMessengerError::Inbox(error);
551                self.record_rejection(&error);
552                return Err(error);
553            }
554        };
555        let admission = self.record_admission(envelope, delivery, started);
556        self.activate_prepared(&admission).await
557    }
558
559    /// Follow an already-attached Ordinary Root from the original executing
560    /// Supervisor. The concrete inbox revalidates and retains all authority
561    /// locks through durable admission; a prior inspect_link is never a grant.
562    pub async fn supervisor_followup(
563        &self,
564        supervisor: &SupervisorReference,
565        target_session_id: &str,
566        operation_id: &str,
567        message: &str,
568    ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
569        bamboo_domain::validate_supervisor_target_id(target_session_id)
570            .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
571        SessionMessageId::parse(operation_id)
572            .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
573        if message.trim().is_empty() || message.len() > MAX_SUPERVISOR_FOLLOWUP_BYTES {
574            return Err(SessionMessengerError::InvalidEnvelope(format!(
575                "followup message must contain 1..={MAX_SUPERVISOR_FOLLOWUP_BYTES} UTF-8 bytes"
576            )));
577        }
578        let envelope = SessionMessageEnvelope {
579            id: SessionMessageId::stable(
580                "supervisor_followup",
581                &serde_json::json!({
582                    "supervisor": supervisor,
583                    "target_session_id": target_session_id,
584                    "operation_id": operation_id,
585                }),
586            ),
587            source: SessionMessageSource::Session {
588                session_id: supervisor.session_id.clone(),
589            },
590            target_session_id: target_session_id.to_string(),
591            kind: SessionMessageKind::PeerMessage,
592            body: SessionMessageBody::Content(SessionMessageContent::text(message)),
593            created_at: chrono::Utc::now(),
594            thread_id: None,
595            in_reply_to: None,
596            attempt: None,
597            correlation_id: Some(operation_id.to_string()),
598        };
599        let started = Instant::now();
600        let delivery = self
601            .inbox
602            .deliver_supervisor_followup(supervisor, &envelope)
603            .await
604            .map_err(|error| {
605                let error = SessionMessengerError::Inbox(error);
606                self.record_rejection(&error);
607                error
608            })?;
609        let admission = self.record_admission(envelope, delivery, started);
610        // Its specific-message RespectSpecificWait intent was published while
611        // holding Supervisor authority. This grants no staged sibling prefix.
612        self.activate_prepared(&admission).await
613    }
614
615    async fn load_session(&self, id: &str) -> Result<Option<Session>, SessionMessengerError> {
616        self.sessions
617            .load_session(id)
618            .await
619            .map_err(|error| SessionMessengerError::Storage(error.to_string()))
620    }
621
622    fn logical_root(session: &Session) -> &str {
623        if session.root_session_id.trim().is_empty() {
624            &session.id
625        } else {
626            &session.root_session_id
627        }
628    }
629
630    async fn validate_relationship(
631        &self,
632        envelope: &SessionMessageEnvelope,
633    ) -> Result<(), SessionMessengerError> {
634        envelope
635            .validate()
636            .map_err(|error| SessionMessengerError::InvalidEnvelope(error.to_string()))?;
637        let target = self
638            .load_session(&envelope.target_session_id)
639            .await?
640            .ok_or_else(|| {
641                SessionMessengerError::TargetNotFound(envelope.target_session_id.clone())
642            })?;
643
644        let SessionMessageSource::Session { session_id } = &envelope.source else {
645            return Ok(());
646        };
647        let source = self
648            .load_session(session_id)
649            .await?
650            .ok_or_else(|| SessionMessengerError::SourceNotFound(session_id.clone()))?;
651
652        let same_root = Self::logical_root(&source) == Self::logical_root(&target);
653        let source_project = source.project_id_meta();
654        let target_project = target.project_id_meta();
655        let project_compatible = match (source_project.as_deref(), target_project.as_deref()) {
656            (Some(left), Some(right)) => left == right,
657            // Older sessions did not persist project_id. Same-root ancestry is
658            // the compatibility authority until both sides have migrated.
659            _ => true,
660        };
661        if !same_root || !project_compatible {
662            return Err(SessionMessengerError::Unauthorized {
663                source_session_id: source.id,
664                target: target.id,
665            });
666        }
667        Ok(())
668    }
669
670    fn record_rejection(&self, error: &SessionMessengerError) {
671        self.metrics.rejected.fetch_add(1, Ordering::Relaxed);
672        match error {
673            SessionMessengerError::InvalidEnvelope(_) => {
674                self.metrics
675                    .invalid_envelope
676                    .fetch_add(1, Ordering::Relaxed);
677            }
678            SessionMessengerError::Unauthorized { .. }
679            | SessionMessengerError::SourceNotFound(_)
680            | SessionMessengerError::TargetNotFound(_) => {
681                self.metrics.unauthorized.fetch_add(1, Ordering::Relaxed);
682            }
683            SessionMessengerError::Inbox(SessionInboxError::AdmissionCancelled) => {}
684            SessionMessengerError::Inbox(SessionInboxError::PayloadTooLarge { .. }) => {
685                self.metrics
686                    .payload_too_large
687                    .fetch_add(1, Ordering::Relaxed);
688            }
689            SessionMessengerError::Inbox(SessionInboxError::BacklogFull { .. }) => {
690                self.metrics.backlog_full.fetch_add(1, Ordering::Relaxed);
691            }
692            SessionMessengerError::Inbox(_) | SessionMessengerError::Storage(_) => {
693                self.metrics.storage_failed.fetch_add(1, Ordering::Relaxed);
694            }
695            SessionMessengerError::Activation { .. }
696            | SessionMessengerError::ActivationEligibility { .. } => {}
697        }
698    }
699
700    pub async fn admit(
701        &self,
702        envelope: SessionMessageEnvelope,
703    ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
704        self.admit_with_gate(envelope, None).await
705    }
706
707    pub async fn admit_with_gate(
708        &self,
709        envelope: SessionMessageEnvelope,
710        gate: Option<&bamboo_domain::AdmissionGate>,
711    ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
712        self.admit_with_delivery_intent(envelope, gate, None).await
713    }
714
715    /// Commit this specific message's immediate permission together with the
716    /// envelope. Coordinators continue to use staged [`admit`](Self::admit).
717    pub async fn admit_with_activation_intent(
718        &self,
719        envelope: SessionMessageEnvelope,
720        policy: SessionActivationPolicy,
721        gate: Option<&bamboo_domain::AdmissionGate>,
722    ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
723        self.admit_with_delivery_intent(envelope, gate, Some(policy))
724            .await
725    }
726
727    async fn admit_with_delivery_intent(
728        &self,
729        envelope: SessionMessageEnvelope,
730        gate: Option<&bamboo_domain::AdmissionGate>,
731        policy: Option<SessionActivationPolicy>,
732    ) -> Result<SessionMessengerAdmission, SessionMessengerError> {
733        let started = Instant::now();
734        if let Err(error) = self.validate_relationship(&envelope).await {
735            self.record_rejection(&error);
736            tracing::warn!(
737                message_id = %envelope.id,
738                target_session_id = %envelope.target_session_id,
739                error = %error,
740                "session message rejected"
741            );
742            return Err(error);
743        }
744
745        let delivery = match (policy, gate) {
746            (Some(policy), gate) => {
747                self.inbox
748                    .deliver_with_activation_intent(&envelope, policy, gate)
749                    .await
750            }
751            (None, Some(gate)) => self.inbox.deliver_with_gate(&envelope, gate).await,
752            (None, None) => self.inbox.deliver(&envelope).await,
753        };
754        let delivery = match delivery {
755            Ok(receipt) => receipt,
756            Err(error) => {
757                let error = SessionMessengerError::Inbox(error);
758                self.record_rejection(&error);
759                return Err(error);
760            }
761        };
762        Ok(self.record_admission(envelope, delivery, started))
763    }
764
765    fn record_admission(
766        &self,
767        envelope: SessionMessageEnvelope,
768        delivery: SessionInboxReceipt,
769        started: Instant,
770    ) -> SessionMessengerAdmission {
771        self.metrics.delivered.fetch_add(1, Ordering::Relaxed);
772        self.metrics.delivery_latency_micros.fetch_add(
773            started.elapsed().as_micros().min(u128::from(u64::MAX)) as u64,
774            Ordering::Relaxed,
775        );
776        SessionMessengerAdmission {
777            envelope_id: envelope.id.as_str().to_string(),
778            target_session_id: envelope.target_session_id,
779            delivery,
780        }
781    }
782
783    pub async fn activate(
784        &self,
785        admission: &SessionMessengerAdmission,
786    ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
787        self.activate_with_policy(admission, SessionActivationPolicy::InterruptSpecificWait)
788            .await
789    }
790
791    pub async fn activate_with_policy(
792        &self,
793        admission: &SessionMessengerAdmission,
794        policy: SessionActivationPolicy,
795    ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
796        if let Err(error) = self
797            .inbox
798            .mark_activation_eligible(
799                &admission.target_session_id,
800                admission.delivery.generation,
801                policy,
802            )
803            .await
804        {
805            self.metrics
806                .activation_failed
807                .fetch_add(1, Ordering::Relaxed);
808            return Err(SessionMessengerError::ActivationEligibility {
809                receipt_id: admission.delivery.id.to_string(),
810                receipt: admission.delivery.clone(),
811                source: error,
812            });
813        }
814        self.activate_prepared(admission).await
815    }
816
817    /// Request execution for an admission whose activation policy was already
818    /// durably recorded by a coordinator.
819    pub async fn activate_prepared(
820        &self,
821        admission: &SessionMessengerAdmission,
822    ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
823        let activation = match self
824            .activation
825            .request_activation(&admission.target_session_id, admission.delivery.generation)
826            .await
827        {
828            Ok(disposition) => disposition,
829            Err(source) => {
830                self.metrics
831                    .activation_failed
832                    .fetch_add(1, Ordering::Relaxed);
833                tracing::error!(
834                    message_id = %admission.envelope_id,
835                    target_session_id = %admission.target_session_id,
836                    generation = admission.delivery.generation,
837                    error = %source,
838                    "session message durable but activation failed"
839                );
840                return Err(SessionMessengerError::Activation {
841                    receipt_id: admission.delivery.id.to_string(),
842                    receipt: admission.delivery.clone(),
843                    source,
844                });
845            }
846        };
847        match activation {
848            SessionActivationDisposition::ActiveNotified => {
849                self.metrics.active_notified.fetch_add(1, Ordering::Relaxed);
850            }
851            SessionActivationDisposition::ActivationReserved => {
852                self.metrics
853                    .activation_reserved
854                    .fetch_add(1, Ordering::Relaxed);
855            }
856            SessionActivationDisposition::ActivationCoalesced => {
857                self.metrics
858                    .activation_coalesced
859                    .fetch_add(1, Ordering::Relaxed);
860            }
861        }
862        tracing::info!(
863            message_id = %admission.envelope_id,
864            target_session_id = %admission.target_session_id,
865            generation = admission.delivery.generation,
866            ?activation,
867            "session message durably delivered"
868        );
869        Ok(SessionMessengerReceipt {
870            delivery: admission.delivery.clone(),
871            activation,
872        })
873    }
874
875    /// Durably authorize the admitted queue prefix before a related
876    /// control-plane transition is committed.
877    ///
878    /// Coordinators call this while their specific wait is still armed, then
879    /// checkpoint the wait clear, and finally call [`activate`](Self::activate).
880    /// A crash in either gap is recoverable: startup sees the watermark, while
881    /// the real reservation adapter refuses to run until the specific wait has
882    /// been durably cleared.
883    pub async fn prepare_activation(
884        &self,
885        admission: &SessionMessengerAdmission,
886    ) -> Result<(), SessionMessengerError> {
887        self.inbox
888            .mark_activation_eligible(
889                &admission.target_session_id,
890                admission.delivery.generation,
891                SessionActivationPolicy::RespectSpecificWait,
892            )
893            .await
894            .map_err(SessionMessengerError::Inbox)
895    }
896
897    /// Authorize one staged delivery, preserving its immutable delivery
898    /// intent and every sibling's existing execution permission.
899    pub async fn prepare_message_activation(
900        &self,
901        admission: &SessionMessengerAdmission,
902        policy: SessionActivationPolicy,
903    ) -> Result<(), SessionMessengerError> {
904        self.inbox
905            .mark_message_activation_eligible(
906                &admission.target_session_id,
907                &admission.delivery,
908                policy,
909            )
910            .await
911            .map_err(SessionMessengerError::Inbox)
912    }
913
914    pub async fn send(
915        &self,
916        envelope: SessionMessageEnvelope,
917    ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
918        self.send_with_gate(envelope, None).await
919    }
920
921    pub async fn send_with_gate(
922        &self,
923        envelope: SessionMessageEnvelope,
924        gate: Option<&bamboo_domain::AdmissionGate>,
925    ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
926        let admission = self
927            .admit_with_activation_intent(
928                envelope,
929                SessionActivationPolicy::InterruptSpecificWait,
930                gate,
931            )
932            .await?;
933        self.activate_prepared(&admission).await
934    }
935
936    pub async fn send_with_policy(
937        &self,
938        envelope: SessionMessageEnvelope,
939        policy: SessionActivationPolicy,
940    ) -> Result<SessionMessengerReceipt, SessionMessengerError> {
941        let admission = self
942            .admit_with_activation_intent(envelope, policy, None)
943            .await?;
944        self.activate_prepared(&admission).await
945    }
946}
947
948#[cfg(test)]
949mod tests {
950    use super::*;
951    use async_trait::async_trait;
952    use bamboo_domain::{
953        SessionActivationDisposition, SessionInboxLimits, SessionMessageBody,
954        SessionMessageContent, SessionMessageId, SessionMessageKind,
955    };
956    use bamboo_storage::{FileSessionInbox, SessionStoreV2};
957    use tempfile::TempDir;
958
959    struct RecordingActivation {
960        calls: tokio::sync::Mutex<Vec<(String, u64)>>,
961    }
962
963    struct FailingActivation;
964
965    #[async_trait]
966    impl SessionActivationPort for FailingActivation {
967        async fn request_activation(
968            &self,
969            _target_session_id: &str,
970            _inbox_generation: u64,
971        ) -> Result<SessionActivationDisposition, SessionActivationError> {
972            Err(SessionActivationError::Internal(
973                "injected wakeup failure".into(),
974            ))
975        }
976    }
977
978    #[tokio::test]
979    async fn immediate_activation_failure_preserves_receipt_and_restart_permission() {
980        let (temp, store, recording, _messenger) = fixture().await;
981        store
982            .save_session(&Session::new("root", "model"))
983            .await
984            .unwrap();
985        let inbox = Arc::new(FileSessionInbox::new(
986            store.clone(),
987            SessionInboxLimits::default(),
988        ));
989        let messenger = SessionMessenger::new(store, inbox, Arc::new(FailingActivation));
990        let envelope = SessionMessageEnvelope::user_input("root", "steer");
991        let gate = bamboo_domain::AdmissionGate::default();
992        let receipt = match messenger
993            .send_with_gate(envelope.clone(), Some(&gate))
994            .await
995            .unwrap_err()
996        {
997            SessionMessengerError::Activation { receipt, .. } => receipt,
998            error => panic!("expected durable delivery with failed wakeup: {error}"),
999        };
1000        assert!(gate.is_committed());
1001        gate.cancel_if_pending();
1002        let restarted_store = Arc::new(SessionStoreV2::new(temp.path().into()).await.unwrap());
1003        let restarted = Arc::new(FileSessionInbox::new(
1004            restarted_store.clone(),
1005            SessionInboxLimits::default(),
1006        ));
1007        let backlog = restarted.inspect("root").await.unwrap();
1008        assert!(backlog.activation_pending());
1009        assert_eq!(backlog.coordinator_generation, 0);
1010        // This is the startup reconciliation request: the durable generation
1011        // comes from this message's intent while the coordinator prefix is 0.
1012        recording
1013            .request_activation("root", backlog.activation_generation)
1014            .await
1015            .unwrap();
1016        let retry = SessionMessenger::new(restarted_store, restarted.clone(), recording.clone());
1017        assert_eq!(
1018            retry
1019                .send_with_gate(envelope, Some(&gate))
1020                .await
1021                .unwrap()
1022                .delivery,
1023            receipt
1024        );
1025        assert_eq!(restarted.inspect("root").await.unwrap().pending, 1);
1026        assert_eq!(messenger.metrics().snapshot().activation_failed, 1);
1027        let claims = restarted.claim("root", 128).await.unwrap();
1028        assert_eq!(claims.len(), 1);
1029        assert_eq!(claims[0].envelope.id, receipt.id);
1030    }
1031
1032    struct FailEligibilityOnce {
1033        inner: Arc<FileSessionInbox>,
1034        fail_next: std::sync::atomic::AtomicBool,
1035    }
1036
1037    #[async_trait]
1038    impl SessionInboxPort for FailEligibilityOnce {
1039        async fn deliver(
1040            &self,
1041            envelope: &SessionMessageEnvelope,
1042        ) -> Result<SessionInboxReceipt, SessionInboxError> {
1043            self.inner.deliver(envelope).await
1044        }
1045
1046        async fn deliver_with_gate(
1047            &self,
1048            envelope: &SessionMessageEnvelope,
1049            gate: &bamboo_domain::AdmissionGate,
1050        ) -> Result<SessionInboxReceipt, SessionInboxError> {
1051            self.inner.deliver_with_gate(envelope, gate).await
1052        }
1053
1054        async fn mark_activation_eligible(
1055            &self,
1056            target_session_id: &str,
1057            generation: u64,
1058            policy: SessionActivationPolicy,
1059        ) -> Result<(), SessionInboxError> {
1060            if self.fail_next.swap(false, Ordering::SeqCst) {
1061                return Err(SessionInboxError::Storage(
1062                    "injected activation watermark failure".into(),
1063                ));
1064            }
1065            self.inner
1066                .mark_activation_eligible(target_session_id, generation, policy)
1067                .await
1068        }
1069
1070        async fn claim(
1071            &self,
1072            target_session_id: &str,
1073            limit: usize,
1074        ) -> Result<Vec<bamboo_domain::SessionInboxClaim>, SessionInboxError> {
1075            self.inner.claim(target_session_id, limit).await
1076        }
1077
1078        async fn was_admitted(
1079            &self,
1080            target_session_id: &str,
1081            id: &SessionMessageId,
1082        ) -> Result<bool, SessionInboxError> {
1083            self.inner.was_admitted(target_session_id, id).await
1084        }
1085
1086        async fn ack(
1087            &self,
1088            target_session_id: &str,
1089            claim: &bamboo_domain::SessionInboxClaim,
1090        ) -> Result<(), SessionInboxError> {
1091            self.inner.ack(target_session_id, claim).await
1092        }
1093
1094        async fn inspect(
1095            &self,
1096            target_session_id: &str,
1097        ) -> Result<bamboo_domain::SessionInboxBacklog, SessionInboxError> {
1098            self.inner.inspect(target_session_id).await
1099        }
1100    }
1101
1102    #[async_trait]
1103    impl SessionActivationPort for RecordingActivation {
1104        async fn request_activation(
1105            &self,
1106            target_session_id: &str,
1107            inbox_generation: u64,
1108        ) -> Result<SessionActivationDisposition, SessionActivationError> {
1109            self.calls
1110                .lock()
1111                .await
1112                .push((target_session_id.to_string(), inbox_generation));
1113            Ok(SessionActivationDisposition::ActivationReserved)
1114        }
1115    }
1116
1117    async fn fixture() -> (
1118        TempDir,
1119        Arc<SessionStoreV2>,
1120        Arc<RecordingActivation>,
1121        SessionMessenger,
1122    ) {
1123        let temp = TempDir::new().unwrap();
1124        let store = Arc::new(
1125            SessionStoreV2::new(temp.path().to_path_buf())
1126                .await
1127                .unwrap(),
1128        );
1129        let activation = Arc::new(RecordingActivation {
1130            calls: tokio::sync::Mutex::new(Vec::new()),
1131        });
1132        let inbox = Arc::new(FileSessionInbox::new(
1133            store.clone(),
1134            SessionInboxLimits::default(),
1135        ));
1136        let messenger = SessionMessenger::new(store.clone(), inbox, activation.clone());
1137        (temp, store, activation, messenger)
1138    }
1139
1140    fn peer(source: &str, target: &str, id: &str) -> SessionMessageEnvelope {
1141        SessionMessageEnvelope {
1142            id: SessionMessageId::parse(id).unwrap(),
1143            source: SessionMessageSource::Session {
1144                session_id: source.to_string(),
1145            },
1146            target_session_id: target.to_string(),
1147            kind: SessionMessageKind::PeerMessage,
1148            body: SessionMessageBody::Content(SessionMessageContent::text("hello")),
1149            created_at: chrono::Utc::now(),
1150            thread_id: None,
1151            in_reply_to: None,
1152            attempt: None,
1153            correlation_id: None,
1154        }
1155    }
1156
1157    #[derive(Default)]
1158    struct PeerMemoryStorage(tokio::sync::RwLock<std::collections::HashMap<String, Session>>);
1159
1160    #[async_trait]
1161    impl Storage for PeerMemoryStorage {
1162        async fn save_session(&self, session: &Session) -> std::io::Result<()> {
1163            self.0
1164                .write()
1165                .await
1166                .insert(session.id.clone(), session.clone());
1167            Ok(())
1168        }
1169
1170        async fn load_session(&self, id: &str) -> std::io::Result<Option<Session>> {
1171            Ok(self.0.read().await.get(id).cloned())
1172        }
1173
1174        async fn delete_session(&self, id: &str) -> std::io::Result<bool> {
1175            Ok(self.0.write().await.remove(id).is_some())
1176        }
1177
1178        async fn list_child_run_statuses(
1179            &self,
1180            parent: &str,
1181        ) -> std::io::Result<Vec<(String, Option<String>)>> {
1182            Ok(self
1183                .0
1184                .read()
1185                .await
1186                .values()
1187                .filter(|session| session.parent_session_id.as_deref() == Some(parent))
1188                .map(|session| (session.id.clone(), session.last_run_status()))
1189                .collect())
1190        }
1191    }
1192
1193    async fn seed_peer_tree(store: &dyn Storage) {
1194        let mut root = Session::new("peer-root", "model");
1195        root.title = "Logical root".into();
1196        root.set_project_id_meta("peer-project");
1197        let left = Session::new_child_of("peer-left", &root, "model", "Left");
1198        let right = Session::new_child_of("peer-right", &root, "model", "Right");
1199        let cousin_left = Session::new_child_of("peer-left-leaf", &left, "model", "Left leaf");
1200        let cousin_right = Session::new_child_of("peer-right-leaf", &right, "model", "Right leaf");
1201        for mut session in [root, left, right, cousin_left, cousin_right] {
1202            session.set_project_id_meta("peer-project");
1203            store.save_session(&session).await.unwrap();
1204        }
1205    }
1206
1207    struct PausedPeerAdmission {
1208        inner: Arc<FileSessionInbox>,
1209        entered: Arc<tokio::sync::Notify>,
1210        release: Arc<tokio::sync::Notify>,
1211    }
1212
1213    #[async_trait]
1214    impl SessionInboxPort for PausedPeerAdmission {
1215        async fn deliver(
1216            &self,
1217            envelope: &SessionMessageEnvelope,
1218        ) -> Result<SessionInboxReceipt, SessionInboxError> {
1219            self.inner.deliver(envelope).await
1220        }
1221
1222        async fn deliver_peer_message(
1223            &self,
1224            envelope: &SessionMessageEnvelope,
1225            source: &ActorSession,
1226            target: &ActorSession,
1227            reply_to: Option<&SessionMessageEnvelope>,
1228        ) -> Result<SessionInboxReceipt, SessionInboxError> {
1229            // The engine has already captured both births and built its id;
1230            // actual filesystem admission has not yet acquired its authority.
1231            self.entered.notify_one();
1232            self.release.notified().await;
1233            self.inner
1234                .deliver_peer_message(envelope, source, target, reply_to)
1235                .await
1236        }
1237
1238        async fn mark_activation_eligible(
1239            &self,
1240            target: &str,
1241            generation: u64,
1242            policy: SessionActivationPolicy,
1243        ) -> Result<(), SessionInboxError> {
1244            self.inner
1245                .mark_activation_eligible(target, generation, policy)
1246                .await
1247        }
1248
1249        async fn claim(
1250            &self,
1251            target: &str,
1252            limit: usize,
1253        ) -> Result<Vec<bamboo_domain::SessionInboxClaim>, SessionInboxError> {
1254            self.inner.claim(target, limit).await
1255        }
1256
1257        async fn was_admitted(
1258            &self,
1259            target: &str,
1260            id: &SessionMessageId,
1261        ) -> Result<bool, SessionInboxError> {
1262            self.inner.was_admitted(target, id).await
1263        }
1264
1265        async fn ack(
1266            &self,
1267            target: &str,
1268            claim: &bamboo_domain::SessionInboxClaim,
1269        ) -> Result<(), SessionInboxError> {
1270            self.inner.ack(target, claim).await
1271        }
1272
1273        async fn inspect(
1274            &self,
1275            target: &str,
1276        ) -> Result<bamboo_domain::SessionInboxBacklog, SessionInboxError> {
1277            self.inner.inspect(target).await
1278        }
1279    }
1280
1281    #[tokio::test]
1282    async fn peer_send_and_reply_reject_either_lifetime_recreated_before_admission() {
1283        for reply in [false, true] {
1284            for replaced_id in ["peer-left", "peer-right"] {
1285                let (_temp, store, activation, messenger) = fixture().await;
1286                seed_peer_tree(store.as_ref()).await;
1287                let received_id = if reply {
1288                    let incoming = messenger
1289                        .send_peer("peer-left", "peer-right", "incoming", "question")
1290                        .await
1291                        .unwrap();
1292                    admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right")
1293                        .await;
1294                    Some(incoming.delivery.id.to_string())
1295                } else {
1296                    None
1297                };
1298                activation.calls.lock().await.clear();
1299                let (caller, target) = if reply {
1300                    ("peer-right", "peer-left")
1301                } else {
1302                    ("peer-left", "peer-right")
1303                };
1304                let entered = Arc::new(tokio::sync::Notify::new());
1305                let release = Arc::new(tokio::sync::Notify::new());
1306                let inbox = Arc::new(FileSessionInbox::new(
1307                    store.clone(),
1308                    SessionInboxLimits::default(),
1309                ));
1310                let paused = SessionMessenger::new(
1311                    store.clone(),
1312                    Arc::new(PausedPeerAdmission {
1313                        inner: inbox.clone(),
1314                        entered: entered.clone(),
1315                        release: release.clone(),
1316                    }),
1317                    activation.clone(),
1318                );
1319                let sending = tokio::spawn(async move {
1320                    if let Some(id) = received_id {
1321                        paused
1322                            .reply_peer(caller, &id, "racing", "old lifetime")
1323                            .await
1324                    } else {
1325                        paused
1326                            .send_peer(caller, target, "racing", "old lifetime")
1327                            .await
1328                    }
1329                });
1330                tokio::time::timeout(std::time::Duration::from_secs(5), entered.notified())
1331                    .await
1332                    .unwrap();
1333                let old = store.load_session(replaced_id).await.unwrap().unwrap();
1334                assert!(store.delete_session(replaced_id).await.unwrap());
1335                let root = store.load_session("peer-root").await.unwrap().unwrap();
1336                let mut replacement =
1337                    Session::new_child_of(replaced_id, &root, "model", "Replacement");
1338                replacement.created_at = old.created_at + chrono::Duration::seconds(1);
1339                replacement.set_project_id_meta("peer-project");
1340                store.save_session(&replacement).await.unwrap();
1341                release.notify_one();
1342                let result = tokio::time::timeout(std::time::Duration::from_secs(5), sending)
1343                    .await
1344                    .unwrap()
1345                    .unwrap();
1346                assert!(result.is_err(), "reply={reply}, replaced={replaced_id}");
1347                let backlog = inbox.inspect(target).await.unwrap();
1348                assert_eq!(backlog.generation, 0);
1349                assert_eq!(backlog.pending, 0);
1350                assert!(!backlog.activation_pending());
1351                assert!(activation.calls.lock().await.is_empty());
1352                // Same ids, tree and Project are legal for a fresh operation;
1353                // only the stale captured lifetime must fail closed.
1354                let current = messenger
1355                    .send_peer(caller, target, "current-lifetime", "current")
1356                    .await
1357                    .unwrap();
1358                assert_eq!(current.delivery.generation, 1);
1359                assert_eq!(activation.calls.lock().await.len(), 1);
1360            }
1361        }
1362    }
1363
1364    #[tokio::test]
1365    async fn peer_reply_revalidates_received_proof_after_concurrent_clear_or_save() {
1366        for clear in [true, false] {
1367            let (_temp, store, activation, messenger) = fixture().await;
1368            seed_peer_tree(store.as_ref()).await;
1369            let incoming = messenger
1370                .send_peer("peer-left", "peer-right", "incoming", "question")
1371                .await
1372                .unwrap();
1373            admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
1374            activation.calls.lock().await.clear();
1375            let before = store.load_session("peer-right").await.unwrap().unwrap();
1376            let received_id = incoming.delivery.id.to_string();
1377            let entered = Arc::new(tokio::sync::Notify::new());
1378            let release = Arc::new(tokio::sync::Notify::new());
1379            let inbox = Arc::new(FileSessionInbox::new(
1380                store.clone(),
1381                SessionInboxLimits::default(),
1382            ));
1383            let paused = SessionMessenger::new(
1384                store.clone(),
1385                Arc::new(PausedPeerAdmission {
1386                    inner: inbox.clone(),
1387                    entered: entered.clone(),
1388                    release: release.clone(),
1389                }),
1390                activation.clone(),
1391            );
1392            let old_id = received_id.clone();
1393            let replying = tokio::spawn(async move {
1394                paused
1395                    .reply_peer("peer-right", &old_id, "racing-proof", "reply")
1396                    .await
1397            });
1398            tokio::time::timeout(std::time::Duration::from_secs(5), entered.notified())
1399                .await
1400                .unwrap();
1401            if clear {
1402                assert!(store.clear_session("peer-right").await.unwrap());
1403            } else {
1404                let mut changed = before.clone();
1405                changed.messages.retain(|message| message.id != received_id);
1406                store.save_session(&changed).await.unwrap();
1407            }
1408            let after = store.load_session("peer-right").await.unwrap().unwrap();
1409            assert_eq!(before.created_at, after.created_at);
1410            assert_eq!(before.root_session_id, after.root_session_id);
1411            assert_eq!(before.project_id_meta(), after.project_id_meta());
1412            assert!(!after
1413                .messages
1414                .iter()
1415                .any(|message| message.id == received_id));
1416            release.notify_one();
1417            let result = tokio::time::timeout(std::time::Duration::from_secs(5), replying)
1418                .await
1419                .unwrap()
1420                .unwrap();
1421            assert!(
1422                result.is_err(),
1423                "clear={clear}: removed proof must not admit a reply"
1424            );
1425            let backlog = inbox.inspect("peer-left").await.unwrap();
1426            assert_eq!(backlog.generation, 0);
1427            assert_eq!(backlog.pending, 0);
1428            assert!(!backlog.activation_pending());
1429            assert!(activation.calls.lock().await.is_empty());
1430
1431            let current = messenger
1432                .send_peer("peer-left", "peer-right", "current-question", "current")
1433                .await
1434                .unwrap();
1435            admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
1436            let reply = messenger
1437                .reply_peer(
1438                    "peer-right",
1439                    current.delivery.id.as_str(),
1440                    "racing-proof",
1441                    "reply",
1442                )
1443                .await
1444                .unwrap();
1445            let retry = messenger
1446                .reply_peer(
1447                    "peer-right",
1448                    current.delivery.id.as_str(),
1449                    "racing-proof",
1450                    "reply",
1451                )
1452                .await
1453                .unwrap();
1454            assert_eq!(reply.delivery.generation, 1);
1455            assert_eq!(retry.delivery.generation, reply.delivery.generation);
1456            assert_eq!(retry.delivery.id, reply.delivery.id);
1457            let backlog = inbox.inspect("peer-left").await.unwrap();
1458            assert_eq!(backlog.generation, 1);
1459            assert_eq!(backlog.pending, 1);
1460        }
1461    }
1462
1463    #[tokio::test]
1464    async fn peer_admission_without_integrated_lifetime_fence_is_unsupported() {
1465        let (_temp, store, activation, _messenger) = fixture().await;
1466        seed_peer_tree(store.as_ref()).await;
1467        let inbox = Arc::new(FileSessionInbox::new(
1468            store.clone(),
1469            SessionInboxLimits::default(),
1470        ));
1471        let messenger = SessionMessenger::new(
1472            store,
1473            Arc::new(FailEligibilityOnce {
1474                inner: inbox.clone(),
1475                fail_next: std::sync::atomic::AtomicBool::new(false),
1476            }),
1477            activation.clone(),
1478        );
1479        let error = messenger
1480            .send_peer("peer-left", "peer-right", "unsupported", "message")
1481            .await
1482            .unwrap_err();
1483        assert!(error
1484            .to_string()
1485            .contains("lifetime-fenced peer SessionInbox admission is unsupported"));
1486        assert_eq!(inbox.inspect("peer-right").await.unwrap().pending, 0);
1487        assert!(activation.calls.lock().await.is_empty());
1488    }
1489
1490    async fn admit_peer_for_test(
1491        store: &dyn Storage,
1492        inbox: &dyn SessionInboxPort,
1493        target: &str,
1494    ) -> SessionMessageEnvelope {
1495        let claims = inbox.claim(target, 16).await.unwrap();
1496        assert_eq!(claims.len(), 1);
1497        let claim = &claims[0];
1498        let mut session = store.load_session(target).await.unwrap().unwrap();
1499        session.add_message(claim.envelope.to_provider_message().unwrap());
1500        store.save_session(&session).await.unwrap();
1501        inbox.ack(target, claim).await.unwrap();
1502        claim.envelope.clone()
1503    }
1504
1505    #[tokio::test]
1506    async fn peer_api_discovers_and_messages_parent_child_siblings_and_cousins() {
1507        let (_temp, store, activation, messenger) = fixture().await;
1508        seed_peer_tree(store.as_ref()).await;
1509        store
1510            .save_session(&Session::new("foreign-root", "model"))
1511            .await
1512            .unwrap();
1513        let targets = messenger.list_peer_targets("peer-left-leaf").await.unwrap();
1514        assert!(!targets.truncated);
1515        let ids: Vec<_> = targets
1516            .targets
1517            .iter()
1518            .map(|target| target.session_id.as_str())
1519            .collect();
1520        assert_eq!(
1521            ids,
1522            ["peer-root", "peer-left", "peer-right", "peer-right-leaf"]
1523        );
1524        assert_eq!(targets.targets[0].title, "Logical root");
1525        assert!(targets.targets[0].parent_session_id.is_none());
1526        assert!(!serde_json::to_string(&targets)
1527            .unwrap()
1528            .contains("foreign-root"));
1529
1530        for (index, (source, target)) in [
1531            ("peer-root", "peer-left"),
1532            ("peer-left", "peer-root"),
1533            ("peer-left", "peer-right"),
1534            ("peer-left-leaf", "peer-right-leaf"),
1535        ]
1536        .into_iter()
1537        .enumerate()
1538        {
1539            let receipt = messenger
1540                .send_peer(
1541                    source,
1542                    target,
1543                    &format!("operation-{index}"),
1544                    "bounded peer instruction",
1545                )
1546                .await
1547                .unwrap();
1548            let backlog = messenger.inbox().inspect(target).await.unwrap();
1549            assert_eq!(
1550                backlog.coordinator_generation, 0,
1551                "peer permission must not release a prefix"
1552            );
1553            assert!(backlog.interrupt_pending());
1554            let envelope =
1555                admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), target).await;
1556            assert_eq!(envelope.id, receipt.delivery.id);
1557            assert_eq!(
1558                envelope.source,
1559                SessionMessageSource::Session {
1560                    session_id: source.into()
1561                }
1562            );
1563            assert_eq!(envelope.thread_id.as_deref(), Some(envelope.id.as_str()));
1564            assert_eq!(
1565                envelope.correlation_id.as_deref(),
1566                Some(format!("operation-{index}").as_str())
1567            );
1568            assert!(envelope.in_reply_to.is_none());
1569        }
1570        assert_eq!(activation.calls.lock().await.len(), 4);
1571    }
1572
1573    #[tokio::test]
1574    async fn peer_api_denies_unknown_deleted_cross_root_and_invalid_project_or_lineage() {
1575        let (_temp, store, activation, messenger) = fixture().await;
1576        seed_peer_tree(store.as_ref()).await;
1577        store
1578            .save_session(&Session::new("foreign-root", "model"))
1579            .await
1580            .unwrap();
1581        for (source, target) in [
1582            ("peer-left", "foreign-root"),
1583            ("unknown", "peer-left"),
1584            ("peer-left", "unknown"),
1585            ("peer-left", "peer-left"),
1586        ] {
1587            assert!(messenger
1588                .send_peer(source, target, "denied-operation", "instruction")
1589                .await
1590                .is_err());
1591        }
1592        store.delete_session("peer-right-leaf").await.unwrap();
1593        assert!(matches!(
1594            messenger
1595                .send_peer("peer-left", "peer-right-leaf", "deleted", "instruction")
1596                .await,
1597            Err(SessionMessengerError::TargetNotFound(_))
1598        ));
1599        assert!(matches!(
1600            messenger
1601                .send_peer(
1602                    "peer-right-leaf",
1603                    "peer-left",
1604                    "deleted-source",
1605                    "instruction"
1606                )
1607                .await,
1608            Err(SessionMessengerError::SourceNotFound(_))
1609        ));
1610
1611        // Invalid canonical snapshots exercise the read boundary without
1612        // relaxing the production store's immutable lineage writer checks.
1613        let memory = Arc::new(PeerMemoryStorage::default());
1614        seed_peer_tree(memory.as_ref()).await;
1615        let strict = SessionMessenger::new(
1616            memory.clone(),
1617            messenger.inbox().clone(),
1618            activation.clone(),
1619        );
1620        let original = memory.load_session("peer-right").await.unwrap().unwrap();
1621        for defect in [
1622            "project",
1623            "missing_project",
1624            "project_mirror",
1625            "parent",
1626            "depth",
1627            "root",
1628            "cycle",
1629        ] {
1630            let mut invalid = original.clone();
1631            match defect {
1632                "project" => invalid.set_project_id_meta("other-project"),
1633                "missing_project" => {
1634                    invalid.metadata.remove("project_id");
1635                    if let Some(metadata) = invalid.runtime_metadata.as_mut() {
1636                        metadata.project_id = None;
1637                    }
1638                }
1639                "project_mirror" => {
1640                    invalid
1641                        .metadata
1642                        .insert("project_id".into(), "other-project".into());
1643                }
1644                "parent" => invalid.parent_session_id = Some("missing-parent".into()),
1645                "depth" => invalid.spawn_depth += 1,
1646                "root" => invalid.root_session_id = "foreign-root".into(),
1647                "cycle" => invalid.parent_session_id = Some("peer-right-leaf".into()),
1648                _ => unreachable!(),
1649            }
1650            memory.save_session(&invalid).await.unwrap();
1651            assert!(
1652                strict
1653                    .send_peer("peer-left", "peer-right", "invalid", "instruction")
1654                    .await
1655                    .is_err(),
1656                "{defect}"
1657            );
1658            let targets = strict.list_peer_targets("peer-left").await.unwrap();
1659            assert!(
1660                !targets
1661                    .targets
1662                    .iter()
1663                    .any(|target| target.session_id == "peer-right"),
1664                "{defect}"
1665            );
1666            memory.save_session(&original).await.unwrap();
1667        }
1668        assert!(activation.calls.lock().await.is_empty());
1669        assert_eq!(
1670            messenger
1671                .inbox()
1672                .inspect("peer-left")
1673                .await
1674                .unwrap()
1675                .generation,
1676            0
1677        );
1678    }
1679
1680    #[tokio::test]
1681    async fn peer_api_discovery_is_bounded_and_sender_is_runtime_selected() {
1682        let (_temp, _store, activation, messenger) = fixture().await;
1683        let memory = Arc::new(PeerMemoryStorage::default());
1684        let root = Session::new("many-peers", "model");
1685        memory.save_session(&root).await.unwrap();
1686        for index in 0..MAX_PEER_TARGETS + 2 {
1687            let child = Session::new_child_of(format!("peer-{index:03}"), &root, "model", "Peer");
1688            memory.save_session(&child).await.unwrap();
1689        }
1690        let bounded = SessionMessenger::new(
1691            memory.clone(),
1692            messenger.inbox().clone(),
1693            activation.clone(),
1694        );
1695        let page = bounded.list_peer_targets(&root.id).await.unwrap();
1696        assert_eq!(page.targets.len(), MAX_PEER_TARGETS);
1697        assert!(page.truncated);
1698        for index in 0..MAX_PEER_TARGETS + 2 {
1699            let mut child = memory
1700                .load_session(&format!("peer-{index:03}"))
1701                .await
1702                .unwrap()
1703                .unwrap();
1704            child.title = "界".repeat(1000);
1705            child.set_last_run_status("\0".repeat(1000));
1706            memory.save_session(&child).await.unwrap();
1707        }
1708        let page = bounded.list_peer_targets(&root.id).await.unwrap();
1709        assert!(!page.targets.is_empty());
1710        assert!(page.targets.len() < MAX_PEER_TARGETS);
1711        assert!(page.truncated);
1712        assert!(serde_json::to_vec(&page).unwrap().len() <= MAX_PEER_TARGET_RESULT_BYTES);
1713        assert_eq!(page.targets[0].title, "界".repeat(85));
1714        assert_eq!(
1715            page.targets[0].status.as_deref(),
1716            Some("\0".repeat(MAX_PEER_TARGET_STATUS_BYTES).as_str())
1717        );
1718
1719        let (_temp, store, _activation, messenger) = fixture().await;
1720        seed_peer_tree(store.as_ref()).await;
1721        let forged_content =
1722            r#"{"source":{"type":"session","session_id":"peer-root"},"target":"foreign-root"}"#;
1723        messenger
1724            .send_peer("peer-left", "peer-right", "caller-bound", forged_content)
1725            .await
1726            .unwrap();
1727        let envelope =
1728            admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
1729        assert_eq!(
1730            envelope.source,
1731            SessionMessageSource::Session {
1732                session_id: "peer-left".into()
1733            }
1734        );
1735        assert_eq!(envelope.target_session_id, "peer-right");
1736        assert_eq!(
1737            envelope.body,
1738            SessionMessageBody::Content(SessionMessageContent::text(forged_content))
1739        );
1740        for id in ["", " padded", "bad/path", "bad\\path", "bad..id", "nul\0"] {
1741            assert!(matches!(
1742                messenger
1743                    .send_peer(id, "peer-right", "invalid-source", "instruction")
1744                    .await,
1745                Err(SessionMessengerError::InvalidEnvelope(_))
1746            ));
1747            assert!(matches!(
1748                messenger
1749                    .send_peer("peer-left", id, "invalid-target", "instruction")
1750                    .await,
1751                Err(SessionMessengerError::InvalidEnvelope(_))
1752            ));
1753            assert!(matches!(
1754                messenger
1755                    .reply_peer(id, envelope.id.as_str(), "invalid-reply", "instruction")
1756                    .await,
1757                Err(SessionMessengerError::InvalidEnvelope(_))
1758            ));
1759        }
1760        assert!(matches!(
1761            messenger.list_peer_targets(&"x".repeat(257)).await,
1762            Err(SessionMessengerError::InvalidEnvelope(_))
1763        ));
1764        for (operation, message) in [
1765            ("", "valid"),
1766            (" padded", "valid"),
1767            ("bad..id", "valid"),
1768            ("valid", " "),
1769            ("valid", "nul\0"),
1770        ] {
1771            assert!(messenger
1772                .send_peer("peer-left", "peer-right", operation, message)
1773                .await
1774                .is_err());
1775        }
1776        assert!(messenger
1777            .send_peer(
1778                "peer-left",
1779                "peer-right",
1780                "oversized",
1781                &"x".repeat(MAX_PEER_MESSAGE_BYTES + 1)
1782            )
1783            .await
1784            .is_err());
1785    }
1786
1787    #[tokio::test]
1788    async fn peer_api_retry_dedupes_before_claim_and_after_ack_restart_but_rejects_changed_payload()
1789    {
1790        let (temp, store, activation, messenger) = fixture().await;
1791        seed_peer_tree(store.as_ref()).await;
1792        let first = messenger
1793            .send_peer("peer-left", "peer-right", "stable-operation", "original")
1794            .await
1795            .unwrap();
1796        let retry = messenger
1797            .send_peer("peer-left", "peer-right", "stable-operation", "original")
1798            .await
1799            .unwrap();
1800        assert_eq!(retry.delivery, first.delivery);
1801        assert!(matches!(
1802            messenger
1803                .send_peer("peer-left", "peer-right", "stable-operation", "changed")
1804                .await,
1805            Err(SessionMessengerError::Inbox(
1806                SessionInboxError::InvalidClaim(_)
1807            ))
1808        ));
1809        assert_eq!(
1810            messenger
1811                .inbox()
1812                .inspect("peer-right")
1813                .await
1814                .unwrap()
1815                .generation,
1816            1
1817        );
1818        admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
1819        drop(messenger);
1820        drop(store);
1821        let reopened = Arc::new(SessionStoreV2::new(temp.path().into()).await.unwrap());
1822        let inbox = Arc::new(FileSessionInbox::new(
1823            reopened.clone(),
1824            SessionInboxLimits::default(),
1825        ));
1826        let messenger = SessionMessenger::new(reopened, inbox.clone(), activation);
1827        let after_ack = messenger
1828            .send_peer("peer-left", "peer-right", "stable-operation", "original")
1829            .await
1830            .unwrap();
1831        assert_eq!(after_ack.delivery, first.delivery);
1832        assert!(inbox.claim("peer-right", 16).await.unwrap().is_empty());
1833        assert!(matches!(
1834            messenger
1835                .send_peer("peer-left", "peer-right", "stable-operation", "changed")
1836                .await,
1837            Err(SessionMessengerError::Inbox(
1838                SessionInboxError::InvalidClaim(_)
1839            ))
1840        ));
1841        assert_eq!(inbox.inspect("peer-right").await.unwrap().generation, 1);
1842    }
1843
1844    #[tokio::test]
1845    async fn peer_api_reply_recovers_durable_recipient_and_thread_after_restart() {
1846        let (temp, store, activation, messenger) = fixture().await;
1847        seed_peer_tree(store.as_ref()).await;
1848        let first = messenger
1849            .send_peer("peer-left-leaf", "peer-right-leaf", "initial", "question")
1850            .await
1851            .unwrap();
1852        let incoming = admit_peer_for_test(
1853            store.as_ref(),
1854            messenger.inbox().as_ref(),
1855            "peer-right-leaf",
1856        )
1857        .await;
1858        drop(messenger);
1859        drop(store);
1860        let store = Arc::new(SessionStoreV2::new(temp.path().into()).await.unwrap());
1861        let inbox = Arc::new(FileSessionInbox::new(
1862            store.clone(),
1863            SessionInboxLimits::default(),
1864        ));
1865        let messenger = SessionMessenger::new(store.clone(), inbox.clone(), activation);
1866        let reply = messenger
1867            .reply_peer(
1868                "peer-right-leaf",
1869                first.delivery.id.as_str(),
1870                "answer",
1871                "answer text",
1872            )
1873            .await
1874            .unwrap();
1875        let retry = messenger
1876            .reply_peer(
1877                "peer-right-leaf",
1878                first.delivery.id.as_str(),
1879                "answer",
1880                "answer text",
1881            )
1882            .await
1883            .unwrap();
1884        assert_eq!(reply.delivery, retry.delivery);
1885        let response = admit_peer_for_test(store.as_ref(), inbox.as_ref(), "peer-left-leaf").await;
1886        assert_eq!(
1887            response.source,
1888            SessionMessageSource::Session {
1889                session_id: "peer-right-leaf".into()
1890            }
1891        );
1892        assert_eq!(response.in_reply_to, Some(incoming.id.clone()));
1893        assert_eq!(response.thread_id, incoming.thread_id);
1894        assert_eq!(response.correlation_id.as_deref(), Some("answer"));
1895        let second_reply = messenger
1896            .reply_peer("peer-left-leaf", response.id.as_str(), "thanks", "thanks")
1897            .await
1898            .unwrap();
1899        let second_response =
1900            admit_peer_for_test(store.as_ref(), inbox.as_ref(), "peer-right-leaf").await;
1901        assert_eq!(second_reply.delivery.id, second_response.id);
1902        assert_eq!(second_response.in_reply_to, Some(response.id));
1903        assert_eq!(
1904            second_response.thread_id.as_deref(),
1905            Some(first.delivery.id.as_str())
1906        );
1907        let after_ack = messenger
1908            .reply_peer(
1909                "peer-right-leaf",
1910                first.delivery.id.as_str(),
1911                "answer",
1912                "answer text",
1913            )
1914            .await
1915            .unwrap();
1916        assert_eq!(after_ack.delivery, reply.delivery);
1917        assert!(inbox.claim("peer-left-leaf", 16).await.unwrap().is_empty());
1918        assert!(messenger
1919            .reply_peer(
1920                "peer-right-leaf",
1921                first.delivery.id.as_str(),
1922                "answer",
1923                "different"
1924            )
1925            .await
1926            .is_err());
1927    }
1928
1929    #[tokio::test]
1930    async fn peer_api_reply_rejects_missing_forged_legacy_and_recreated_source() {
1931        let (_temp, store, activation, messenger) = fixture().await;
1932        seed_peer_tree(store.as_ref()).await;
1933        let mut receiver = store.load_session("peer-right").await.unwrap().unwrap();
1934        let legacy = peer("peer-left", "peer-right", "legacy-correction");
1935        receiver.add_message(legacy.to_provider_message().unwrap());
1936        let user = SessionMessageEnvelope::user_input("peer-right", "ordinary input");
1937        receiver.add_message(user.to_provider_message().unwrap());
1938        let sender = store.load_session("peer-left").await.unwrap().unwrap();
1939        let mut forged = SessionMessenger::peer_envelope(
1940            &sender,
1941            &receiver,
1942            "forged",
1943            "original",
1944            SessionMessenger::peer_message_id(&sender, &receiver, "forged"),
1945            "thread".into(),
1946            None,
1947        )
1948        .to_provider_message()
1949        .unwrap();
1950        forged.content = "changed without canonical proof".into();
1951        receiver.add_message(forged);
1952        store.save_session(&receiver).await.unwrap();
1953        for id in [
1954            "missing",
1955            legacy.id.as_str(),
1956            user.id.as_str(),
1957            receiver.messages.last().unwrap().id.as_str(),
1958        ] {
1959            assert!(messenger
1960                .reply_peer("peer-right", id, "deny-reply", "reply")
1961                .await
1962                .is_err());
1963        }
1964        let incoming = messenger
1965            .send_peer("peer-left", "peer-right", "old-lifetime", "question")
1966            .await
1967            .unwrap();
1968        admit_peer_for_test(store.as_ref(), messenger.inbox().as_ref(), "peer-right").await;
1969        let memory = Arc::new(PeerMemoryStorage::default());
1970        // Retain the actual saved receiver while replacing the sender's birth.
1971        for id in ["peer-root", "peer-left", "peer-right"] {
1972            let mut session = store.load_session(id).await.unwrap().unwrap();
1973            if id == "peer-left" {
1974                session.created_at += chrono::Duration::seconds(1);
1975            }
1976            memory.save_session(&session).await.unwrap();
1977        }
1978        let fenced = SessionMessenger::new(
1979            memory.clone(),
1980            messenger.inbox().clone(),
1981            activation.clone(),
1982        );
1983        assert!(matches!(
1984            fenced
1985                .reply_peer(
1986                    "peer-right",
1987                    incoming.delivery.id.as_str(),
1988                    "reply-old",
1989                    "reply"
1990                )
1991                .await,
1992            Err(SessionMessengerError::InvalidEnvelope(_))
1993        ));
1994        memory.delete_session("peer-left").await.unwrap();
1995        assert!(matches!(
1996            fenced
1997                .reply_peer(
1998                    "peer-right",
1999                    incoming.delivery.id.as_str(),
2000                    "reply-deleted",
2001                    "reply"
2002                )
2003                .await,
2004            Err(SessionMessengerError::TargetNotFound(_))
2005        ));
2006        assert_eq!(activation.calls.lock().await.len(), 1);
2007    }
2008
2009    #[tokio::test]
2010    async fn same_root_delivery_enqueues_then_requests_activation() {
2011        let (_temp, store, activation, messenger) = fixture().await;
2012        let root = Session::new("root", "model");
2013        let mut child = Session::new("child", "model");
2014        child.kind = bamboo_domain::SessionKind::Child;
2015        child.parent_session_id = Some("root".to_string());
2016        child.root_session_id = "root".to_string();
2017        store.save_session(&root).await.unwrap();
2018        store.save_session(&child).await.unwrap();
2019
2020        let receipt = messenger
2021            .send(peer("root", "child", "msg-1"))
2022            .await
2023            .unwrap();
2024        assert_eq!(receipt.delivery.generation, 1);
2025        assert_eq!(
2026            activation.calls.lock().await.as_slice(),
2027            &[("child".to_string(), 1)]
2028        );
2029        assert_eq!(messenger.metrics().snapshot().delivered, 1);
2030    }
2031
2032    #[tokio::test]
2033    async fn staged_delivery_without_watermark_reports_exact_retry_required() {
2034        let temp = TempDir::new().unwrap();
2035        let store = Arc::new(
2036            SessionStoreV2::new(temp.path().to_path_buf())
2037                .await
2038                .unwrap(),
2039        );
2040        let root = Session::new("root", "model");
2041        let mut child = Session::new("child", "model");
2042        child.kind = bamboo_domain::SessionKind::Child;
2043        child.parent_session_id = Some("root".to_string());
2044        child.root_session_id = "root".to_string();
2045        store.save_session(&root).await.unwrap();
2046        store.save_session(&child).await.unwrap();
2047        let inbox = Arc::new(FailEligibilityOnce {
2048            inner: Arc::new(FileSessionInbox::new(
2049                store.clone(),
2050                SessionInboxLimits::default(),
2051            )),
2052            fail_next: std::sync::atomic::AtomicBool::new(true),
2053        });
2054        let activation = Arc::new(RecordingActivation {
2055            calls: tokio::sync::Mutex::new(Vec::new()),
2056        });
2057        let messenger = SessionMessenger::new(store.clone(), inbox, activation.clone());
2058        let envelope = peer("root", "child", "watermark-failure");
2059        let gate = bamboo_domain::AdmissionGate::default();
2060
2061        let admission = messenger
2062            .admit_with_gate(envelope.clone(), Some(&gate))
2063            .await
2064            .unwrap();
2065        let receipt = match messenger.activate(&admission).await.unwrap_err() {
2066            SessionMessengerError::ActivationEligibility { receipt, .. } => receipt,
2067            error => panic!("expected retry-required classification: {error}"),
2068        };
2069        assert!(gate.is_committed());
2070        assert!(activation.calls.lock().await.is_empty());
2071        let reopened = FileSessionInbox::new(store, SessionInboxLimits::default());
2072        let backlog = reopened.inspect("child").await.unwrap();
2073        assert_eq!(backlog.pending + backlog.claimed, 1);
2074        assert_eq!(backlog.activation_generation, 0);
2075        assert_eq!(backlog.generation, receipt.generation);
2076
2077        let retry = messenger
2078            .admit_with_gate(envelope, Some(&gate))
2079            .await
2080            .unwrap();
2081        let retried = messenger.activate(&retry).await.unwrap();
2082        assert_eq!(retried.delivery, receipt);
2083        assert_eq!(activation.calls.lock().await.len(), 1);
2084        let reopened = reopened.inspect("child").await.unwrap();
2085        assert_eq!(reopened.activation_generation, receipt.generation);
2086        assert_eq!(reopened.pending + reopened.claimed, 1);
2087    }
2088
2089    #[tokio::test]
2090    async fn cross_root_peer_is_rejected_before_enqueue() {
2091        let (_temp, store, activation, messenger) = fixture().await;
2092        store
2093            .save_session(&Session::new("root-a", "model"))
2094            .await
2095            .unwrap();
2096        store
2097            .save_session(&Session::new("root-b", "model"))
2098            .await
2099            .unwrap();
2100
2101        let error = messenger
2102            .send(peer("root-a", "root-b", "msg-2"))
2103            .await
2104            .unwrap_err();
2105        assert!(matches!(error, SessionMessengerError::Unauthorized { .. }));
2106        assert!(activation.calls.lock().await.is_empty());
2107        let metrics = messenger.metrics().snapshot();
2108        assert_eq!(metrics.rejected, 1);
2109        assert_eq!(metrics.unauthorized, 1);
2110        assert_eq!(metrics.payload_too_large, 0);
2111        assert_eq!(metrics.backlog_full, 0);
2112    }
2113
2114    #[tokio::test]
2115    async fn same_root_different_project_peer_is_rejected_before_enqueue() {
2116        let (_temp, store, activation, messenger) = fixture().await;
2117        let mut source = Session::new("project-root", "model");
2118        source.set_project_id_meta("project-a");
2119        let mut target = Session::new("project-child", "model");
2120        target.kind = bamboo_domain::SessionKind::Child;
2121        target.parent_session_id = Some(source.id.clone());
2122        target.root_session_id = source.id.clone();
2123        target.set_project_id_meta("project-b");
2124        store.save_session(&source).await.unwrap();
2125        store.save_session(&target).await.unwrap();
2126
2127        let error = messenger
2128            .send(peer(&source.id, &target.id, "different-project"))
2129            .await
2130            .unwrap_err();
2131        assert!(matches!(error, SessionMessengerError::Unauthorized { .. }));
2132        assert!(activation.calls.lock().await.is_empty());
2133        let metrics = messenger.metrics().snapshot();
2134        assert_eq!(metrics.delivered, 0);
2135        assert_eq!(metrics.rejected, 1);
2136        assert_eq!(metrics.unauthorized, 1);
2137    }
2138
2139    #[tokio::test]
2140    async fn limit_rejections_have_distinct_metrics() {
2141        let temp = TempDir::new().unwrap();
2142        let store = Arc::new(
2143            SessionStoreV2::new(temp.path().to_path_buf())
2144                .await
2145                .unwrap(),
2146        );
2147        store
2148            .save_session(&Session::new("target", "model"))
2149            .await
2150            .unwrap();
2151        let activation = Arc::new(RecordingActivation {
2152            calls: tokio::sync::Mutex::new(Vec::new()),
2153        });
2154        let inbox = Arc::new(FileSessionInbox::new(
2155            store.clone(),
2156            SessionInboxLimits {
2157                max_payload_bytes: 512,
2158                max_backlog: 1,
2159                max_claim_batch: 1,
2160            },
2161        ));
2162        let messenger = SessionMessenger::new(store, inbox, activation.clone());
2163
2164        let oversized = SessionMessageEnvelope::user_input("target", "x".repeat(2048));
2165        assert!(matches!(
2166            messenger.send(oversized).await,
2167            Err(SessionMessengerError::Inbox(
2168                SessionInboxError::PayloadTooLarge { .. }
2169            ))
2170        ));
2171        messenger
2172            .send(SessionMessageEnvelope::user_input("target", "first"))
2173            .await
2174            .unwrap();
2175        assert!(matches!(
2176            messenger
2177                .send(SessionMessageEnvelope::user_input("target", "second"))
2178                .await,
2179            Err(SessionMessengerError::Inbox(
2180                SessionInboxError::BacklogFull { .. }
2181            ))
2182        ));
2183
2184        let metrics = messenger.metrics().snapshot();
2185        assert_eq!(metrics.rejected, 2);
2186        assert_eq!(metrics.payload_too_large, 1);
2187        assert_eq!(metrics.backlog_full, 1);
2188        assert_eq!(activation.calls.lock().await.len(), 1);
2189    }
2190}