Skip to main content

bamboo_domain/session/
inbox.rs

1//! Typed, durable session-to-session delivery contract.
2//!
3//! A [`SessionMessageEnvelope`] belongs to Bamboo's delivery plane. It is
4//! addressed by the stable logical [`Session::id`](super::Session), regardless
5//! of which process, worker, or activation currently owns the session. The
6//! envelope is translated into a provider-valid [`Message`] only when it is
7//! admitted to reasoning context.
8
9use std::collections::{HashSet, VecDeque};
10
11use async_trait::async_trait;
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15
16use super::{Message, MessagePart, Role};
17
18/// Serialize JSON with every object key sorted recursively.
19///
20/// Stable message ids and durable inbox idempotency receipts must use the same
21/// representation: callers can construct semantically identical nested JSON
22/// objects with different insertion orders (notably when serde_json's
23/// `preserve_order` feature is enabled).
24pub fn canonical_json_bytes(value: &serde_json::Value) -> Vec<u8> {
25    fn canonicalize(value: &serde_json::Value) -> serde_json::Value {
26        match value {
27            serde_json::Value::Array(values) => {
28                serde_json::Value::Array(values.iter().map(canonicalize).collect())
29            }
30            serde_json::Value::Object(values) => {
31                let mut entries = values.iter().collect::<Vec<_>>();
32                entries.sort_unstable_by_key(|(key, _)| *key);
33                let mut canonical = serde_json::Map::new();
34                for (key, value) in entries {
35                    canonical.insert(key.clone(), canonicalize(value));
36                }
37                serde_json::Value::Object(canonical)
38            }
39            scalar => scalar.clone(),
40        }
41    }
42
43    // `serde_json::Value` serialization is infallible in practice. Keeping a
44    // deterministic fallback makes the helper total and preserves the prior
45    // stable-id contract if serde_json ever introduces a fallible value.
46    serde_json::to_vec(&canonicalize(value)).unwrap_or_else(|_| b"null".to_vec())
47}
48
49/// Maximum number of recently admitted ids retained in the session checkpoint.
50///
51/// The transcript remains an additional, unbounded source of dedupe truth:
52/// admitted messages use the envelope id as their stable [`Message::id`].
53pub const SESSION_INBOX_ADMITTED_CAPACITY: usize = 4096;
54
55/// Globally unique idempotency key supplied by the sender.
56#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
57#[serde(transparent)]
58pub struct SessionMessageId(String);
59
60impl SessionMessageId {
61    pub fn new() -> Self {
62        Self(uuid::Uuid::new_v4().to_string())
63    }
64
65    pub fn parse(value: impl Into<String>) -> Result<Self, SessionMessageValidationError> {
66        let value = value.into();
67        let trimmed = value.trim();
68        if trimmed.is_empty() {
69            return Err(SessionMessageValidationError::EmptyMessageId);
70        }
71        if value != trimmed {
72            return Err(SessionMessageValidationError::NonCanonicalMessageId);
73        }
74        if value.len() > 256 {
75            return Err(SessionMessageValidationError::MessageIdTooLong);
76        }
77        if value.contains('/') || value.contains('\\') || value.contains("..") {
78            return Err(SessionMessageValidationError::UnsafeMessageId);
79        }
80        Ok(Self(value))
81    }
82
83    pub fn as_str(&self) -> &str {
84        &self.0
85    }
86
87    /// Stable id for runtime events whose producer already has a durable
88    /// semantic identity but not a UUID-form message id.
89    pub fn stable(namespace: &str, value: &serde_json::Value) -> Self {
90        let canonical = canonical_json_bytes(value);
91        let mut digest = Sha256::new();
92        digest.update((namespace.len() as u64).to_be_bytes());
93        digest.update(namespace.as_bytes());
94        digest.update((canonical.len() as u64).to_be_bytes());
95        digest.update(canonical);
96        Self(format!("stable-{}", hex::encode(digest.finalize())))
97    }
98
99    /// Stable id for the bounded compatibility migration from the legacy
100    /// `pending_injected_messages` array.
101    pub fn legacy(session_id: &str, index: usize, value: &serde_json::Value) -> Self {
102        let stable = Self::stable(
103            &format!("legacy_pending_injected_messages:{session_id}:{index}"),
104            value,
105        );
106        Self(format!("legacy-{index:08}-{}", stable.0))
107    }
108}
109
110impl<'de> Deserialize<'de> for SessionMessageId {
111    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
112    where
113        D: serde::Deserializer<'de>,
114    {
115        let value = String::deserialize(deserializer)?;
116        Self::parse(value).map_err(serde::de::Error::custom)
117    }
118}
119
120impl Default for SessionMessageId {
121    fn default() -> Self {
122        Self::new()
123    }
124}
125
126impl std::fmt::Display for SessionMessageId {
127    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        formatter.write_str(&self.0)
129    }
130}
131
132/// Authenticated logical sender identity.
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(tag = "type", rename_all = "snake_case")]
135pub enum SessionMessageSource {
136    User,
137    Session { session_id: String },
138    Runtime { subsystem: String },
139}
140
141/// Registered semantic kind. Adding a kind requires an explicit translation
142/// policy in [`SessionMessageEnvelope::to_provider_message`].
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "snake_case")]
145pub enum SessionMessageKind {
146    UserInput,
147    PeerMessage,
148    ChildOutcome,
149    RuntimeInstruction,
150}
151
152/// Provider-neutral text and multimodal content.
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154pub struct SessionMessageContent {
155    #[serde(default)]
156    pub text: String,
157    #[serde(default, skip_serializing_if = "Vec::is_empty")]
158    pub parts: Vec<MessagePart>,
159}
160
161impl SessionMessageContent {
162    pub fn text(value: impl Into<String>) -> Self {
163        Self {
164            text: value.into(),
165            parts: Vec::new(),
166        }
167    }
168
169    fn is_semantic(&self) -> bool {
170        !self.text.trim().is_empty()
171            || self.parts.iter().any(|part| match part {
172                MessagePart::Text { text } => !text.trim().is_empty(),
173                MessagePart::ImageUrl { image_url } => !image_url.url.trim().is_empty(),
174            })
175    }
176}
177
178/// Optional provider-facing presentation selected by a trusted runtime
179/// coordinator.
180///
181/// The typed outcome/instruction remains the durable semantic payload. This
182/// presentation preserves the exact text/multimodal shape and safe message
183/// metadata that the pre-inbox path would have appended to the transcript.
184/// `session_message` is reserved for the canonical envelope proof and is
185/// rejected during validation.
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187pub struct SessionProviderMessage {
188    pub content: SessionMessageContent,
189    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
190    pub metadata: serde_json::Map<String, serde_json::Value>,
191    #[serde(default)]
192    pub never_compress: bool,
193}
194
195/// Typed child terminal outcome carried independently of its rendered prompt.
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197pub struct SessionChildOutcome {
198    pub child_session_id: String,
199    pub status: String,
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub result: Option<String>,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub error: Option<String>,
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub provider_message: Option<SessionProviderMessage>,
206}
207
208/// Typed runtime instruction. `data` is namespaced by the registered
209/// `instruction`; it is never treated as provider-facing role metadata.
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211pub struct SessionRuntimeInstruction {
212    pub instruction: String,
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub content: Option<SessionMessageContent>,
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub data: Option<serde_json::Value>,
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub provider_message: Option<SessionProviderMessage>,
219}
220
221/// Semantic body; delivery metadata is deliberately not embedded here.
222#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
223#[serde(tag = "type", rename_all = "snake_case")]
224pub enum SessionMessageBody {
225    Content(SessionMessageContent),
226    ChildOutcome(SessionChildOutcome),
227    RuntimeInstruction(SessionRuntimeInstruction),
228}
229
230/// Stable, dependency-light envelope persisted in each logical session inbox.
231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232pub struct SessionMessageEnvelope {
233    pub id: SessionMessageId,
234    pub source: SessionMessageSource,
235    pub target_session_id: String,
236    pub kind: SessionMessageKind,
237    pub body: SessionMessageBody,
238    pub created_at: DateTime<Utc>,
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub thread_id: Option<String>,
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub in_reply_to: Option<SessionMessageId>,
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub attempt: Option<u32>,
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub correlation_id: Option<String>,
247}
248
249impl SessionMessageEnvelope {
250    pub fn user_input(target_session_id: impl Into<String>, text: impl Into<String>) -> Self {
251        Self {
252            id: SessionMessageId::new(),
253            source: SessionMessageSource::User,
254            target_session_id: target_session_id.into(),
255            kind: SessionMessageKind::UserInput,
256            body: SessionMessageBody::Content(SessionMessageContent::text(text)),
257            created_at: Utc::now(),
258            thread_id: None,
259            in_reply_to: None,
260            attempt: None,
261            correlation_id: None,
262        }
263    }
264
265    pub fn validate(&self) -> Result<(), SessionMessageValidationError> {
266        SessionMessageId::parse(self.id.as_str())?;
267        if let Some(in_reply_to) = &self.in_reply_to {
268            SessionMessageId::parse(in_reply_to.as_str())
269                .map_err(|_| SessionMessageValidationError::InvalidInReplyTo)?;
270        }
271        if self.target_session_id.trim().is_empty() {
272            return Err(SessionMessageValidationError::EmptyTarget);
273        }
274        match (&self.source, self.kind, &self.body) {
275            (
276                SessionMessageSource::User,
277                SessionMessageKind::UserInput,
278                SessionMessageBody::Content(_),
279            )
280            | (
281                SessionMessageSource::Session { .. },
282                SessionMessageKind::PeerMessage,
283                SessionMessageBody::Content(_),
284            )
285            | (
286                SessionMessageSource::Session { .. } | SessionMessageSource::Runtime { .. },
287                SessionMessageKind::ChildOutcome,
288                SessionMessageBody::ChildOutcome(_),
289            )
290            | (
291                SessionMessageSource::Runtime { .. },
292                SessionMessageKind::RuntimeInstruction,
293                SessionMessageBody::RuntimeInstruction(_),
294            ) => {}
295            _ => return Err(SessionMessageValidationError::KindSourceBodyMismatch),
296        }
297        match &self.source {
298            SessionMessageSource::Session { session_id } if session_id.trim().is_empty() => {
299                return Err(SessionMessageValidationError::EmptySource)
300            }
301            SessionMessageSource::Runtime { subsystem } if subsystem.trim().is_empty() => {
302                return Err(SessionMessageValidationError::EmptySource)
303            }
304            _ => {}
305        }
306        match &self.body {
307            SessionMessageBody::Content(content) if !content.is_semantic() => {
308                return Err(SessionMessageValidationError::EmptyContent)
309            }
310            SessionMessageBody::ChildOutcome(outcome)
311                if outcome.child_session_id.trim().is_empty() =>
312            {
313                return Err(SessionMessageValidationError::EmptyChildSessionId)
314            }
315            SessionMessageBody::ChildOutcome(outcome) if outcome.status.trim().is_empty() => {
316                return Err(SessionMessageValidationError::EmptyChildStatus)
317            }
318            SessionMessageBody::ChildOutcome(SessionChildOutcome {
319                provider_message: Some(message),
320                ..
321            })
322            | SessionMessageBody::RuntimeInstruction(SessionRuntimeInstruction {
323                provider_message: Some(message),
324                ..
325            }) if !message.content.is_semantic() => {
326                return Err(SessionMessageValidationError::EmptyProviderContent)
327            }
328            SessionMessageBody::ChildOutcome(SessionChildOutcome {
329                provider_message: Some(message),
330                ..
331            })
332            | SessionMessageBody::RuntimeInstruction(SessionRuntimeInstruction {
333                provider_message: Some(message),
334                ..
335            }) if message.metadata.contains_key("session_message") => {
336                return Err(SessionMessageValidationError::ReservedProviderMetadata)
337            }
338            SessionMessageBody::RuntimeInstruction(instruction)
339                if instruction.instruction.trim().is_empty() =>
340            {
341                return Err(SessionMessageValidationError::EmptyRuntimeInstruction)
342            }
343            SessionMessageBody::RuntimeInstruction(SessionRuntimeInstruction {
344                content: Some(content),
345                ..
346            }) if !content.is_semantic() => {
347                return Err(SessionMessageValidationError::EmptyRuntimeContent)
348            }
349            _ => {}
350        }
351        Ok(())
352    }
353
354    /// Canonical fields that define one logical delivery for idempotency and
355    /// transcript-proof purposes. Transport/retry metadata (`created_at` and
356    /// `attempt`) is deliberately excluded. Provider presentation is derived
357    /// by trusted runtime producers and is also excluded: the first durable
358    /// envelope wins presentation for an otherwise identical typed outcome.
359    pub fn idempotency_semantics(&self) -> serde_json::Value {
360        let body = match &self.body {
361            SessionMessageBody::Content(content) => serde_json::json!({
362                "type": "content",
363                "content": content,
364            }),
365            SessionMessageBody::ChildOutcome(outcome) => serde_json::json!({
366                "type": "child_outcome",
367                "child_session_id": outcome.child_session_id,
368                "status": outcome.status,
369                "result": outcome.result,
370                "error": outcome.error,
371            }),
372            SessionMessageBody::RuntimeInstruction(instruction) => serde_json::json!({
373                "type": "runtime_instruction",
374                "instruction": instruction.instruction,
375                "content": instruction.content,
376                "data": instruction.data,
377            }),
378        };
379        serde_json::json!({
380            "source": &self.source,
381            "target_session_id": &self.target_session_id,
382            "kind": &self.kind,
383            "body": body,
384            "thread_id": &self.thread_id,
385            "in_reply_to": &self.in_reply_to,
386            "correlation_id": &self.correlation_id,
387        })
388    }
389
390    /// Translate the delivery envelope to a replayable provider-valid user
391    /// message while retaining all correlation metadata.
392    pub fn to_provider_message(&self) -> Result<Message, SessionMessageValidationError> {
393        self.validate()?;
394        let (text, parts, provider_metadata, never_compress) = match &self.body {
395            SessionMessageBody::Content(content) => (
396                content.text.clone(),
397                content.parts.clone(),
398                serde_json::Map::new(),
399                false,
400            ),
401            SessionMessageBody::ChildOutcome(outcome) => {
402                if let Some(provider_message) = &outcome.provider_message {
403                    (
404                        provider_message.content.text.clone(),
405                        provider_message.content.parts.clone(),
406                        provider_message.metadata.clone(),
407                        provider_message.never_compress,
408                    )
409                } else {
410                    let mut text = format!(
411                        "Child session `{}` finished with status `{}`.",
412                        outcome.child_session_id, outcome.status
413                    );
414                    if let Some(result) = outcome.result.as_deref().filter(|v| !v.trim().is_empty())
415                    {
416                        text.push_str("\n\nResult:\n");
417                        text.push_str(result);
418                    }
419                    if let Some(error) = outcome.error.as_deref().filter(|v| !v.trim().is_empty()) {
420                        text.push_str("\n\nError:\n");
421                        text.push_str(error);
422                    }
423                    (text, Vec::new(), serde_json::Map::new(), false)
424                }
425            }
426            SessionMessageBody::RuntimeInstruction(instruction) => {
427                if let Some(provider_message) = &instruction.provider_message {
428                    (
429                        provider_message.content.text.clone(),
430                        provider_message.content.parts.clone(),
431                        provider_message.metadata.clone(),
432                        provider_message.never_compress,
433                    )
434                } else {
435                    let text = instruction
436                        .content
437                        .as_ref()
438                        .map(|content| content.text.clone())
439                        .unwrap_or_else(|| instruction.instruction.clone());
440                    let parts = instruction
441                        .content
442                        .as_ref()
443                        .map(|content| content.parts.clone())
444                        .unwrap_or_default();
445                    (text, parts, serde_json::Map::new(), false)
446                }
447            }
448        };
449
450        let mut message = if parts.is_empty() {
451            Message::user(text)
452        } else {
453            Message::user_with_parts(text, parts)
454        };
455        message.id = self.id.to_string();
456        message.created_at = self.created_at;
457        message.never_compress = never_compress;
458        let mut metadata = provider_metadata;
459        metadata.insert(
460            "session_message".to_string(),
461            serde_json::json!({
462                "id": self.id,
463                "idempotency_semantics": self.idempotency_semantics(),
464                "source": self.source,
465                "target_session_id": self.target_session_id,
466                "kind": self.kind,
467                "body": self.body,
468                "created_at": self.created_at,
469                "thread_id": self.thread_id,
470                "in_reply_to": self.in_reply_to,
471                "attempt": self.attempt,
472                "correlation_id": self.correlation_id,
473            }),
474        );
475        message.metadata = Some(serde_json::Value::Object(metadata));
476        Ok(message)
477    }
478}
479
480/// Unbounded durable dedupe proof carried by the transcript itself.
481///
482/// The bounded admission cursor may legitimately evict old ids. A permanent
483/// inbox receipt can therefore be reconciled only against this typed transcript
484/// marker carrying the full idempotency-defining envelope semantics, not
485/// against cursor membership alone.
486pub fn is_matching_session_message(message: &Message, envelope: &SessionMessageEnvelope) -> bool {
487    let Ok(expected) = envelope.to_provider_message() else {
488        return false;
489    };
490    let provider_metadata_matches = || {
491        let mut actual = message.metadata.as_ref()?.as_object()?.clone();
492        let mut wanted = expected.metadata.as_ref()?.as_object()?.clone();
493        actual.remove("session_message");
494        wanted.remove("session_message");
495        Some(actual == wanted)
496    };
497    let canonical_marker_matches = || {
498        Some(
499            message.metadata.as_ref()?.get("session_message")?
500                == expected.metadata.as_ref()?.get("session_message")?,
501        )
502    };
503    message.role == Role::User
504        && message.id == envelope.id.as_str()
505        && message.content == expected.content
506        && message.content_parts == expected.content_parts
507        && message.never_compress == expected.never_compress
508        && provider_metadata_matches() == Some(true)
509        && canonical_marker_matches() == Some(true)
510}
511
512#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
513pub enum SessionMessageValidationError {
514    #[error("session message id must not be empty")]
515    EmptyMessageId,
516    #[error("session message id must not contain leading or trailing whitespace")]
517    NonCanonicalMessageId,
518    #[error("session message id exceeds 256 bytes")]
519    MessageIdTooLong,
520    #[error("session message id contains an unsafe path component")]
521    UnsafeMessageId,
522    #[error("target session id must not be empty")]
523    EmptyTarget,
524    #[error("source identity must not be empty")]
525    EmptySource,
526    #[error("message content must contain non-empty text or at least one part")]
527    EmptyContent,
528    #[error("child outcome session id must not be empty")]
529    EmptyChildSessionId,
530    #[error("child outcome status must not be empty")]
531    EmptyChildStatus,
532    #[error("runtime instruction name must not be empty")]
533    EmptyRuntimeInstruction,
534    #[error("runtime instruction content must contain non-empty text or at least one part")]
535    EmptyRuntimeContent,
536    #[error("provider presentation must contain non-empty text or at least one part")]
537    EmptyProviderContent,
538    #[error("provider presentation metadata uses the reserved session_message key")]
539    ReservedProviderMetadata,
540    #[error("in_reply_to must be a valid session message id")]
541    InvalidInReplyTo,
542    #[error("session message kind, source, and body are incompatible")]
543    KindSourceBodyMismatch,
544}
545
546/// One durably admitted id and its mailbox sequence.
547#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
548pub struct AdmittedSessionMessage {
549    pub id: SessionMessageId,
550    pub sequence: u64,
551}
552
553/// Bounded durable cursor stored with the transcript checkpoint.
554#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
555pub struct SessionInboxAdmissionState {
556    #[serde(default)]
557    admitted: VecDeque<AdmittedSessionMessage>,
558    #[serde(skip)]
559    index: HashSet<SessionMessageId>,
560    #[serde(default)]
561    pub last_admitted_sequence: u64,
562    /// Highest durable inbox generation observed by this execution while
563    /// migrating compatibility ingress. This may be ahead of
564    /// `last_admitted_sequence` when the transcript checkpoint failed; outer
565    /// execution finalization uses that gap to hand work to one successor.
566    #[serde(default)]
567    pub last_observed_sequence: u64,
568}
569
570impl SessionInboxAdmissionState {
571    pub fn contains(&self, id: &SessionMessageId) -> bool {
572        self.index.contains(id) || self.admitted.iter().any(|entry| &entry.id == id)
573    }
574
575    pub fn contains_str(&self, id: &str) -> bool {
576        self.admitted.iter().any(|entry| entry.id.as_str() == id)
577    }
578
579    pub fn record(&mut self, id: SessionMessageId, sequence: u64) -> bool {
580        self.last_observed_sequence = self.last_observed_sequence.max(sequence);
581        if self.contains(&id) {
582            self.last_admitted_sequence = self.last_admitted_sequence.max(sequence);
583            return false;
584        }
585        self.index.insert(id.clone());
586        self.admitted
587            .push_back(AdmittedSessionMessage { id, sequence });
588        self.last_admitted_sequence = self.last_admitted_sequence.max(sequence);
589        while self.admitted.len() > SESSION_INBOX_ADMITTED_CAPACITY {
590            if let Some(evicted) = self.admitted.pop_front() {
591                self.index.remove(&evicted.id);
592            }
593        }
594        true
595    }
596
597    pub fn rebuild_index(&mut self) {
598        self.index = self.admitted.iter().map(|entry| entry.id.clone()).collect();
599    }
600
601    pub fn merge_from(&mut self, other: &Self) {
602        for entry in &other.admitted {
603            self.record(entry.id.clone(), entry.sequence);
604        }
605        self.last_admitted_sequence = self
606            .last_admitted_sequence
607            .max(other.last_admitted_sequence);
608        self.last_observed_sequence = self
609            .last_observed_sequence
610            .max(other.last_observed_sequence);
611    }
612
613    /// Record a durable generation before transcript admission. Unlike
614    /// [`record`](Self::record), this does not claim that provider reasoning
615    /// has seen the message.
616    pub fn observe(&mut self, sequence: u64) {
617        self.last_observed_sequence = self.last_observed_sequence.max(sequence);
618    }
619
620    /// Newest durable generation that this execution observed but has not
621    /// durably admitted into the transcript yet.
622    pub fn pending_activation_generation(&self) -> Option<u64> {
623        (self.last_observed_sequence > self.last_admitted_sequence)
624            .then_some(self.last_observed_sequence)
625    }
626
627    pub fn len(&self) -> usize {
628        self.admitted.len()
629    }
630
631    pub fn is_empty(&self) -> bool {
632        self.admitted.is_empty()
633    }
634}
635
636/// Durable enqueue receipt. `generation` is a per-session, monotonic delivery
637/// sequence used by the activation finalization handshake.
638#[derive(Debug, Clone, PartialEq, Eq)]
639pub struct SessionInboxReceipt {
640    pub id: SessionMessageId,
641    pub generation: u64,
642}
643
644/// How an authorized inbox prefix interacts with a durable orchestration wait.
645#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
646#[serde(rename_all = "snake_case")]
647pub enum SessionActivationPolicy {
648    /// Child/Bash coordinators authorize execution only after their own wait
649    /// transition is durably satisfied. A crash before that transition must
650    /// leave the marked backlog inert.
651    #[default]
652    RespectSpecificWait,
653    /// Explicit user/peer/runtime steering is allowed to interrupt a child or
654    /// Bash wait. The real spawner applies that interruption to the latest
655    /// locked control-plane snapshot before reserving a runner.
656    InterruptSpecificWait,
657}
658
659/// Opaque claim returned to the single consumer.
660#[derive(Debug, Clone, PartialEq)]
661pub struct SessionInboxClaim {
662    pub envelope: SessionMessageEnvelope,
663    pub generation: u64,
664    pub claim_id: String,
665}
666
667#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
668pub struct SessionInboxBacklog {
669    pub pending: usize,
670    pub claimed: usize,
671    pub generation: u64,
672    /// Highest inbox generation whose producer durably authorized execution.
673    ///
674    /// Admission alone is intentionally insufficient: child/Bash coordinators
675    /// can stage several sibling outcomes while a durable wait remains armed,
676    /// then authorize the accumulated prefix only after the wait policy is
677    /// satisfied.
678    pub activation_generation: u64,
679    /// Highest authorized generation carrying an explicit external-steering
680    /// policy that may interrupt a specific child/Bash wait.
681    pub interrupt_generation: u64,
682    /// Oldest generation still present in `new/` or `cur/`.
683    pub oldest_generation: Option<u64>,
684}
685
686impl SessionInboxBacklog {
687    /// True only when at least one durable queue item is covered by the
688    /// producer's activation watermark.
689    pub fn activation_pending(&self) -> bool {
690        self.oldest_generation
691            .is_some_and(|oldest| oldest <= self.activation_generation)
692    }
693
694    pub fn interrupt_pending(&self) -> bool {
695        self.oldest_generation
696            .is_some_and(|oldest| oldest <= self.interrupt_generation)
697    }
698}
699
700#[derive(Debug, Clone, Copy, PartialEq, Eq)]
701pub struct SessionInboxLimits {
702    pub max_payload_bytes: usize,
703    pub max_backlog: usize,
704    pub max_claim_batch: usize,
705}
706
707impl Default for SessionInboxLimits {
708    fn default() -> Self {
709        Self {
710            max_payload_bytes: 256 * 1024,
711            max_backlog: 1024,
712            max_claim_batch: 128,
713        }
714    }
715}
716
717#[derive(Debug, thiserror::Error)]
718pub enum SessionInboxError {
719    #[error("session inbox target not found: {0}")]
720    TargetNotFound(String),
721    #[error("session inbox payload is {actual} bytes, limit is {limit}")]
722    PayloadTooLarge { actual: usize, limit: usize },
723    #[error("session inbox backlog is full ({current}/{limit})")]
724    BacklogFull { current: usize, limit: usize },
725    #[error("session inbox claim is invalid: {0}")]
726    InvalidClaim(String),
727    #[error("session inbox storage failure: {0}")]
728    Storage(String),
729}
730
731/// Durable delivery/claim/ack port. Implementations are scoped to one runtime,
732/// but every address is a stable logical session id.
733#[async_trait]
734pub trait SessionInboxPort: Send + Sync {
735    async fn deliver(
736        &self,
737        envelope: &SessionMessageEnvelope,
738    ) -> Result<SessionInboxReceipt, SessionInboxError>;
739
740    /// Durably authorize execution for the queue prefix through `generation`.
741    ///
742    /// This is separate from [`deliver`](Self::deliver) so orchestration
743    /// producers can stage outcomes without violating a persisted wait policy.
744    /// Implementations must make this monotonic and idempotent.
745    async fn mark_activation_eligible(
746        &self,
747        target_session_id: &str,
748        generation: u64,
749        policy: SessionActivationPolicy,
750    ) -> Result<(), SessionInboxError>;
751
752    async fn claim(
753        &self,
754        target_session_id: &str,
755        limit: usize,
756    ) -> Result<Vec<SessionInboxClaim>, SessionInboxError>;
757
758    /// Permanent durable receipt check. Unlike the bounded in-session cursor,
759    /// this tombstone must remain true for the lifetime of the logical inbox.
760    async fn was_admitted(
761        &self,
762        target_session_id: &str,
763        id: &SessionMessageId,
764    ) -> Result<bool, SessionInboxError>;
765
766    /// Persist the permanent admitted-id receipt, then remove the claimed
767    /// queue item. Callers may invoke this only after the transcript checkpoint
768    /// containing the same id is durable.
769    async fn ack(
770        &self,
771        target_session_id: &str,
772        claim: &SessionInboxClaim,
773    ) -> Result<(), SessionInboxError>;
774
775    async fn inspect(
776        &self,
777        target_session_id: &str,
778    ) -> Result<SessionInboxBacklog, SessionInboxError>;
779}
780
781#[derive(Debug, Clone, Copy, PartialEq, Eq)]
782pub enum SessionActivationDisposition {
783    ActiveNotified,
784    ActivationReserved,
785    ActivationCoalesced,
786}
787
788#[derive(Debug, thiserror::Error)]
789pub enum SessionActivationError {
790    #[error("session activation target not found: {0}")]
791    TargetNotFound(String),
792    #[error("session activation failed: {0}")]
793    Internal(String),
794}
795
796/// Runtime-scoped activation port. The delivery service never starts a second
797/// loop directly; it notifies the owner or reserves one successor activation.
798#[async_trait]
799pub trait SessionActivationPort: Send + Sync {
800    async fn request_activation(
801        &self,
802        target_session_id: &str,
803        inbox_generation: u64,
804    ) -> Result<SessionActivationDisposition, SessionActivationError>;
805}
806
807#[cfg(test)]
808mod tests {
809    use super::*;
810    use crate::{ImageUrlRef, Role};
811
812    #[test]
813    fn multimodal_envelope_round_trips_and_keeps_provider_role_valid() {
814        let envelope = SessionMessageEnvelope {
815            id: SessionMessageId::parse("msg-1").unwrap(),
816            source: SessionMessageSource::User,
817            target_session_id: "session-1".to_string(),
818            kind: SessionMessageKind::UserInput,
819            body: SessionMessageBody::Content(SessionMessageContent {
820                text: "inspect this".to_string(),
821                parts: vec![
822                    MessagePart::Text {
823                        text: "inspect this".to_string(),
824                    },
825                    MessagePart::ImageUrl {
826                        image_url: ImageUrlRef {
827                            url: "bamboo-attachment://session-1/image-1".to_string(),
828                            detail: Some("high".to_string()),
829                        },
830                    },
831                ],
832            }),
833            created_at: Utc::now(),
834            thread_id: Some("thread-1".to_string()),
835            in_reply_to: Some(SessionMessageId::parse("msg-0").unwrap()),
836            attempt: Some(2),
837            correlation_id: Some("corr-1".to_string()),
838        };
839
840        let json = serde_json::to_string(&envelope).unwrap();
841        let restored: SessionMessageEnvelope = serde_json::from_str(&json).unwrap();
842        assert_eq!(restored, envelope);
843
844        let message = restored.to_provider_message().unwrap();
845        assert_eq!(message.role, Role::User);
846        assert_eq!(message.id, "msg-1");
847        assert_eq!(message.content_parts.as_ref().unwrap().len(), 2);
848        let metadata = message.metadata.unwrap();
849        assert_eq!(metadata["session_message"]["source"]["type"], "user");
850        assert_eq!(metadata["session_message"]["thread_id"], "thread-1");
851    }
852
853    #[test]
854    fn envelope_deserialization_rejects_noncanonical_and_oversized_ids() {
855        let envelope = SessionMessageEnvelope::user_input("target", "hello");
856        let mut wire = serde_json::to_value(&envelope).unwrap();
857
858        wire["id"] = serde_json::Value::String(" message-id ".to_string());
859        assert!(serde_json::from_value::<SessionMessageEnvelope>(wire.clone()).is_err());
860
861        wire["id"] = serde_json::Value::String(format!("{}x", " ".repeat(300)));
862        assert!(serde_json::from_value::<SessionMessageEnvelope>(wire.clone()).is_err());
863
864        wire["id"] = serde_json::Value::String("x".repeat(257));
865        assert!(serde_json::from_value::<SessionMessageEnvelope>(wire.clone()).is_err());
866
867        wire["id"] = serde_json::Value::String("message-id".to_string());
868        wire["in_reply_to"] = serde_json::Value::String(" reply-id ".to_string());
869        assert!(serde_json::from_value::<SessionMessageEnvelope>(wire).is_err());
870    }
871
872    #[test]
873    fn kind_source_body_mismatch_is_rejected() {
874        let mut envelope = SessionMessageEnvelope::user_input("target", "hello");
875        envelope.source = SessionMessageSource::Session {
876            session_id: "peer".to_string(),
877        };
878        assert_eq!(
879            envelope.validate().unwrap_err(),
880            SessionMessageValidationError::KindSourceBodyMismatch
881        );
882    }
883
884    #[test]
885    fn semantic_body_and_reply_validation_rejects_empty_values() {
886        let mut envelope = SessionMessageEnvelope::user_input("target", " ");
887        assert_eq!(
888            envelope.validate().unwrap_err(),
889            SessionMessageValidationError::EmptyContent
890        );
891
892        envelope.body = SessionMessageBody::Content(SessionMessageContent {
893            text: String::new(),
894            parts: vec![MessagePart::Text {
895                text: String::new(),
896            }],
897        });
898        assert_eq!(
899            envelope.validate().unwrap_err(),
900            SessionMessageValidationError::EmptyContent,
901            "an empty text part must not make an empty envelope semantic"
902        );
903        envelope.body = SessionMessageBody::Content(SessionMessageContent {
904            text: String::new(),
905            parts: vec![MessagePart::ImageUrl {
906                image_url: ImageUrlRef {
907                    url: "bamboo-attachment://target/image".to_string(),
908                    detail: None,
909                },
910            }],
911        });
912        assert!(envelope.validate().is_ok());
913
914        envelope.in_reply_to = Some(SessionMessageId(" ../bad ".to_string()));
915        assert_eq!(
916            envelope.validate().unwrap_err(),
917            SessionMessageValidationError::InvalidInReplyTo
918        );
919    }
920
921    #[test]
922    fn child_outcome_requires_identity_and_status() {
923        let mut envelope = SessionMessageEnvelope {
924            id: SessionMessageId::parse("child-outcome").unwrap(),
925            source: SessionMessageSource::Session {
926                session_id: "parent".to_string(),
927            },
928            target_session_id: "target".to_string(),
929            kind: SessionMessageKind::ChildOutcome,
930            body: SessionMessageBody::ChildOutcome(SessionChildOutcome {
931                child_session_id: " ".to_string(),
932                status: "completed".to_string(),
933                result: None,
934                error: None,
935                provider_message: None,
936            }),
937            created_at: Utc::now(),
938            thread_id: None,
939            in_reply_to: None,
940            attempt: None,
941            correlation_id: None,
942        };
943        assert_eq!(
944            envelope.validate().unwrap_err(),
945            SessionMessageValidationError::EmptyChildSessionId
946        );
947        let SessionMessageBody::ChildOutcome(outcome) = &mut envelope.body else {
948            unreachable!()
949        };
950        outcome.child_session_id = "child".to_string();
951        outcome.status = String::new();
952        assert_eq!(
953            envelope.validate().unwrap_err(),
954            SessionMessageValidationError::EmptyChildStatus
955        );
956    }
957
958    #[test]
959    fn runtime_instruction_requires_name_and_semantic_optional_content() {
960        let mut envelope = SessionMessageEnvelope {
961            id: SessionMessageId::parse("runtime").unwrap(),
962            source: SessionMessageSource::Runtime {
963                subsystem: "scheduler".to_string(),
964            },
965            target_session_id: "target".to_string(),
966            kind: SessionMessageKind::RuntimeInstruction,
967            body: SessionMessageBody::RuntimeInstruction(SessionRuntimeInstruction {
968                instruction: String::new(),
969                content: None,
970                data: Some(serde_json::json!({"wake": true})),
971                provider_message: None,
972            }),
973            created_at: Utc::now(),
974            thread_id: None,
975            in_reply_to: None,
976            attempt: None,
977            correlation_id: None,
978        };
979        assert_eq!(
980            envelope.validate().unwrap_err(),
981            SessionMessageValidationError::EmptyRuntimeInstruction
982        );
983        {
984            let SessionMessageBody::RuntimeInstruction(instruction) = &mut envelope.body else {
985                unreachable!()
986            };
987            instruction.instruction = "wake".to_string();
988            instruction.content = Some(SessionMessageContent::text(" "));
989        }
990        assert_eq!(
991            envelope.validate().unwrap_err(),
992            SessionMessageValidationError::EmptyRuntimeContent
993        );
994        let SessionMessageBody::RuntimeInstruction(instruction) = &mut envelope.body else {
995            unreachable!()
996        };
997        instruction.content = Some(SessionMessageContent::text("resume"));
998        assert!(envelope.validate().is_ok());
999    }
1000
1001    #[test]
1002    fn durable_transcript_marker_requires_full_envelope_semantics() {
1003        let envelope = SessionMessageEnvelope::user_input("target", "hello");
1004        let message = envelope.to_provider_message().unwrap();
1005        assert!(is_matching_session_message(&message, &envelope));
1006
1007        let mut forged = message.clone();
1008        forged.metadata.as_mut().unwrap()["session_message"]["target_session_id"] =
1009            serde_json::Value::String("other".to_string());
1010        assert!(!is_matching_session_message(&forged, &envelope));
1011        forged = message.clone();
1012        let mut different = envelope.clone();
1013        different.body = SessionMessageBody::Content(SessionMessageContent::text("different"));
1014        forged.metadata = different.to_provider_message().unwrap().metadata;
1015        assert!(!is_matching_session_message(&forged, &envelope));
1016        forged = message.clone();
1017        forged.content = "different".to_string();
1018        assert!(!is_matching_session_message(&forged, &envelope));
1019        forged = message;
1020        forged.role = Role::Assistant;
1021        assert!(!is_matching_session_message(&forged, &envelope));
1022    }
1023
1024    #[test]
1025    fn provider_presentation_proof_is_exact_and_marker_is_reserved() {
1026        let mut provider_metadata = serde_json::Map::new();
1027        provider_metadata.insert("hidden_from_ui".to_string(), serde_json::Value::Bool(true));
1028        provider_metadata.insert(
1029            "runtime_kind".to_string(),
1030            serde_json::Value::String("guardian_verdict".to_string()),
1031        );
1032        let mut envelope = SessionMessageEnvelope {
1033            id: SessionMessageId::parse("child-proof").unwrap(),
1034            source: SessionMessageSource::Runtime {
1035                subsystem: "child_completion".to_string(),
1036            },
1037            target_session_id: "parent".to_string(),
1038            kind: SessionMessageKind::ChildOutcome,
1039            body: SessionMessageBody::ChildOutcome(SessionChildOutcome {
1040                child_session_id: "child".to_string(),
1041                status: "completed".to_string(),
1042                result: Some("approved".to_string()),
1043                error: None,
1044                provider_message: Some(SessionProviderMessage {
1045                    content: SessionMessageContent {
1046                        text: "exact guardian resume".to_string(),
1047                        parts: vec![MessagePart::ImageUrl {
1048                            image_url: ImageUrlRef {
1049                                url: "bamboo-attachment://parent/verdict".to_string(),
1050                                detail: Some("high".to_string()),
1051                            },
1052                        }],
1053                    },
1054                    metadata: provider_metadata,
1055                    never_compress: true,
1056                }),
1057            }),
1058            created_at: Utc::now(),
1059            thread_id: Some("wait-1".to_string()),
1060            in_reply_to: None,
1061            attempt: None,
1062            correlation_id: Some("child".to_string()),
1063        };
1064
1065        let message = envelope.to_provider_message().unwrap();
1066        assert!(is_matching_session_message(&message, &envelope));
1067
1068        let mut forged = message.clone();
1069        forged.content_parts = None;
1070        assert!(!is_matching_session_message(&forged, &envelope));
1071
1072        let mut forged = message.clone();
1073        forged.never_compress = false;
1074        assert!(!is_matching_session_message(&forged, &envelope));
1075
1076        let mut forged = message.clone();
1077        forged.metadata.as_mut().unwrap()["hidden_from_ui"] = serde_json::Value::Bool(false);
1078        assert!(!is_matching_session_message(&forged, &envelope));
1079
1080        let mut forged = message;
1081        forged.metadata.as_mut().unwrap()["session_message"]["idempotency_semantics"]["body"]
1082            ["status"] = serde_json::Value::String("forged".to_string());
1083        assert!(!is_matching_session_message(&forged, &envelope));
1084
1085        let SessionMessageBody::ChildOutcome(outcome) = &mut envelope.body else {
1086            unreachable!()
1087        };
1088        outcome.provider_message.as_mut().unwrap().metadata.insert(
1089            "session_message".to_string(),
1090            serde_json::json!({"forged": true}),
1091        );
1092        assert_eq!(
1093            envelope.validate().unwrap_err(),
1094            SessionMessageValidationError::ReservedProviderMetadata
1095        );
1096    }
1097
1098    #[test]
1099    fn admission_state_is_bounded_and_mergeable() {
1100        let mut state = SessionInboxAdmissionState::default();
1101        for sequence in 1..=(SESSION_INBOX_ADMITTED_CAPACITY as u64 + 1) {
1102            state.record(
1103                SessionMessageId::parse(format!("message-{sequence}")).unwrap(),
1104                sequence,
1105            );
1106        }
1107        assert_eq!(state.len(), SESSION_INBOX_ADMITTED_CAPACITY);
1108        assert_eq!(
1109            state.last_admitted_sequence,
1110            SESSION_INBOX_ADMITTED_CAPACITY as u64 + 1
1111        );
1112        assert!(!state.contains(&SessionMessageId::parse("message-1").unwrap()));
1113
1114        let json = serde_json::to_string(&state).unwrap();
1115        let mut restored: SessionInboxAdmissionState = serde_json::from_str(&json).unwrap();
1116        restored.rebuild_index();
1117        assert!(restored.contains(
1118            &SessionMessageId::parse(format!("message-{}", SESSION_INBOX_ADMITTED_CAPACITY + 1))
1119                .unwrap()
1120        ));
1121    }
1122
1123    #[test]
1124    fn legacy_ids_are_deterministic_and_position_sensitive() {
1125        let value = serde_json::json!({"content": "hello"});
1126        assert_eq!(
1127            SessionMessageId::legacy("session", 0, &value),
1128            SessionMessageId::legacy("session", 0, &value)
1129        );
1130        assert_ne!(
1131            SessionMessageId::legacy("session", 0, &value),
1132            SessionMessageId::legacy("session", 1, &value)
1133        );
1134    }
1135
1136    #[test]
1137    fn stable_ids_use_canonical_sha256_and_distinguish_inputs() {
1138        let left = serde_json::json!({"b": [2, 3], "a": 1});
1139        let mut right_map = serde_json::Map::new();
1140        right_map.insert("a".to_string(), serde_json::json!(1));
1141        right_map.insert("b".to_string(), serde_json::json!([2, 3]));
1142        let right = serde_json::Value::Object(right_map);
1143        let stable = SessionMessageId::stable("runtime", &left);
1144        assert_eq!(stable, SessionMessageId::stable("runtime", &right));
1145        assert_eq!(stable.as_str().len(), "stable-".len() + 64);
1146        assert_ne!(
1147            stable,
1148            SessionMessageId::stable("runtime", &serde_json::json!({"a": 1, "b": [2, 4]}))
1149        );
1150        assert_ne!(stable, SessionMessageId::stable("other", &left));
1151    }
1152}