Skip to main content

bamboo_storage/
session_inbox.rs

1//! Durable SessionInbox adapter backed by the proven sub-agent Maildir.
2//!
3//! Every inbox lives beside the authoritative logical session:
4//! `sessions/<root>[/children/<child>]/inbox/`. The address is always
5//! `Session.id`; the current worker/process/placement never enters the path.
6
7use std::collections::HashMap;
8use std::fs::{File, OpenOptions};
9use std::io::ErrorKind;
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, Mutex, Weak};
12
13use async_trait::async_trait;
14use bamboo_domain::{
15    SessionActivationPolicy, SessionInboxBacklog, SessionInboxClaim, SessionInboxError,
16    SessionInboxLimits, SessionInboxPort, SessionInboxReceipt, SessionMessageEnvelope,
17    SessionMessageId, SessionMessageSource,
18};
19use bamboo_subagent::{AgentRef, InboxKind, InboxMessage, Mailbox, MsgId};
20use base64::Engine;
21use chrono::{TimeZone, Utc};
22use fs2::FileExt;
23use sha2::{Digest, Sha256};
24use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard};
25
26use crate::v2::atomic_write;
27use crate::SessionStoreV2;
28
29const INBOX_DIR: &str = "inbox";
30const GENERATION_FILE: &str = "generation";
31const ACTIVATION_GENERATION_FILE: &str = "activation-generation";
32const INTERRUPT_GENERATION_FILE: &str = "interrupt-generation";
33const ADMITTED_DIR: &str = "admitted";
34const OPERATION_LOCK_FILE: &str = ".session-inbox.lock";
35
36struct FileOperationLock(File);
37
38impl Drop for FileOperationLock {
39    fn drop(&mut self) {
40        let _ = FileExt::unlock(&self.0);
41    }
42}
43
44/// Filesystem SessionInbox implementation. Clone/share one instance per
45/// runtime so concurrent senders serialize only the small generation/backlog
46/// transaction for their target session.
47#[derive(Clone)]
48pub struct FileSessionInbox {
49    sessions: Arc<SessionStoreV2>,
50    limits: SessionInboxLimits,
51    /// Runtime-owned path registry. Clones of this adapter share it, while
52    /// independent AppState/SDK runtimes remain fully isolated.
53    operation_locks: Arc<Mutex<HashMap<PathBuf, Weak<AsyncMutex<()>>>>>,
54}
55
56impl FileSessionInbox {
57    pub fn new(sessions: Arc<SessionStoreV2>, limits: SessionInboxLimits) -> Self {
58        Self {
59            sessions,
60            limits,
61            operation_locks: Arc::new(Mutex::new(HashMap::new())),
62        }
63    }
64
65    pub fn limits(&self) -> SessionInboxLimits {
66        self.limits
67    }
68
69    async fn lock_process(&self, dir: &Path) -> OwnedMutexGuard<()> {
70        let key = dir.to_path_buf();
71        let lock = {
72            let mut locks = self
73                .operation_locks
74                .lock()
75                .unwrap_or_else(std::sync::PoisonError::into_inner);
76            locks.retain(|_, lock| lock.strong_count() > 0);
77            match locks.get(&key).and_then(Weak::upgrade) {
78                Some(lock) => lock,
79                None => {
80                    let lock = Arc::new(AsyncMutex::new(()));
81                    locks.insert(key, Arc::downgrade(&lock));
82                    lock
83                }
84            }
85        };
86        lock.lock_owned().await
87    }
88
89    async fn lock_file(dir: &Path) -> Result<FileOperationLock, SessionInboxError> {
90        tokio::fs::create_dir_all(dir).await.map_err(|error| {
91            SessionInboxError::Storage(format!(
92                "create session inbox directory {}: {error}",
93                dir.display()
94            ))
95        })?;
96        let path = dir.join(OPERATION_LOCK_FILE);
97        tokio::task::spawn_blocking(move || {
98            let file = OpenOptions::new()
99                .create(true)
100                .truncate(false)
101                .read(true)
102                .write(true)
103                .open(&path)
104                .map_err(|error| {
105                    SessionInboxError::Storage(format!(
106                        "open session inbox lock {}: {error}",
107                        path.display()
108                    ))
109                })?;
110            file.lock_exclusive().map_err(|error| {
111                SessionInboxError::Storage(format!(
112                    "lock session inbox {}: {error}",
113                    path.display()
114                ))
115            })?;
116            Ok(FileOperationLock(file))
117        })
118        .await
119        .map_err(|error| SessionInboxError::Storage(format!("join inbox lock task: {error}")))?
120    }
121
122    async fn lock_operation(
123        &self,
124        dir: &Path,
125    ) -> Result<(OwnedMutexGuard<()>, FileOperationLock), SessionInboxError> {
126        // Advisory locks do not reliably serialize two file descriptors in one
127        // process on every platform. The path-keyed process mutex covers that
128        // case; the file lock coordinates separate Bamboo processes.
129        let process = self.lock_process(dir).await;
130        let file = Self::lock_file(dir).await?;
131        Ok((process, file))
132    }
133
134    async fn lock_lifecycle(
135        &self,
136    ) -> Result<crate::v2::SessionLifecycleReadGuard, SessionInboxError> {
137        self.sessions
138            .lock_session_lifecycle_shared()
139            .await
140            .map_err(|error| {
141                SessionInboxError::Storage(format!(
142                    "lock session lifecycle for inbox operation: {error}"
143                ))
144            })
145    }
146
147    async fn inbox_dir(&self, session_id: &str) -> Result<PathBuf, SessionInboxError> {
148        let rel = self
149            .sessions
150            .resolve_rel_path(session_id)
151            .await
152            .ok_or_else(|| SessionInboxError::TargetNotFound(session_id.to_string()))?;
153        let session_dir = self.sessions.bamboo_home_dir().join(rel);
154        match tokio::fs::try_exists(session_dir.join("session.json")).await {
155            Ok(true) => Ok(session_dir.join(INBOX_DIR)),
156            Ok(false) => Err(SessionInboxError::TargetNotFound(session_id.to_string())),
157            Err(error) => Err(SessionInboxError::Storage(format!(
158                "validate SessionInbox target {session_id}: {error}"
159            ))),
160        }
161    }
162
163    async fn read_generation(dir: &Path) -> Result<u64, SessionInboxError> {
164        let path = dir.join(GENERATION_FILE);
165        match tokio::fs::read_to_string(&path).await {
166            Ok(raw) => raw.trim().parse::<u64>().map_err(|error| {
167                SessionInboxError::Storage(format!(
168                    "decode inbox generation {}: {error}",
169                    path.display()
170                ))
171            }),
172            Err(error) if error.kind() == ErrorKind::NotFound => Ok(0),
173            Err(error) => Err(SessionInboxError::Storage(format!(
174                "read inbox generation {}: {error}",
175                path.display()
176            ))),
177        }
178    }
179
180    async fn next_generation(dir: &Path) -> Result<u64, SessionInboxError> {
181        let next = Self::read_generation(dir).await?.saturating_add(1);
182        atomic_write(&dir.join(GENERATION_FILE), next.to_string().as_bytes())
183            .await
184            .map_err(|error| {
185                SessionInboxError::Storage(format!("persist inbox generation: {error}"))
186            })?;
187        Ok(next)
188    }
189
190    async fn read_activation_generation(dir: &Path) -> Result<u64, SessionInboxError> {
191        let path = dir.join(ACTIVATION_GENERATION_FILE);
192        match tokio::fs::read_to_string(&path).await {
193            Ok(raw) => raw.trim().parse::<u64>().map_err(|error| {
194                SessionInboxError::Storage(format!(
195                    "decode inbox activation generation {}: {error}",
196                    path.display()
197                ))
198            }),
199            Err(error) if error.kind() == ErrorKind::NotFound => Ok(0),
200            Err(error) => Err(SessionInboxError::Storage(format!(
201                "read inbox activation generation {}: {error}",
202                path.display()
203            ))),
204        }
205    }
206
207    async fn read_interrupt_generation(dir: &Path) -> Result<u64, SessionInboxError> {
208        let path = dir.join(INTERRUPT_GENERATION_FILE);
209        match tokio::fs::read_to_string(&path).await {
210            Ok(raw) => raw.trim().parse::<u64>().map_err(|error| {
211                SessionInboxError::Storage(format!(
212                    "decode inbox interrupt generation {}: {error}",
213                    path.display()
214                ))
215            }),
216            Err(error) if error.kind() == ErrorKind::NotFound => Ok(0),
217            Err(error) => Err(SessionInboxError::Storage(format!(
218                "read inbox interrupt generation {}: {error}",
219                path.display()
220            ))),
221        }
222    }
223
224    async fn oldest_backlog_generation(dir: &Path) -> Result<Option<u64>, SessionInboxError> {
225        let mut oldest = None;
226        for queue in ["new", "cur"] {
227            for (generation, _, _) in Self::valid_queue_entries(dir, queue).await? {
228                oldest = Some(oldest.map_or(generation, |current: u64| current.min(generation)));
229            }
230        }
231        Ok(oldest)
232    }
233
234    fn wrapper(envelope: &SessionMessageEnvelope, generation: u64) -> InboxMessage {
235        let from = match &envelope.source {
236            SessionMessageSource::User => AgentRef {
237                session_id: "user".to_string(),
238                role: None,
239            },
240            SessionMessageSource::Session { session_id } => AgentRef {
241                session_id: session_id.clone(),
242                role: None,
243            },
244            SessionMessageSource::Runtime { subsystem } => AgentRef {
245                session_id: format!("runtime:{subsystem}"),
246                role: None,
247            },
248        };
249        // The Maildir filename sorts on this transport timestamp. The original
250        // sender timestamp remains intact inside the typed envelope body.
251        let transport_time = Utc.timestamp_nanos(generation.min(i64::MAX as u64) as i64);
252        InboxMessage {
253            // Maildir filenames include MsgId. Hashing keeps the filename well
254            // below NAME_MAX even when the accepted logical id is 256 bytes.
255            // The original id remains authoritative inside the envelope.
256            id: MsgId(format!(
257                "sm-{}",
258                base64::engine::general_purpose::URL_SAFE_NO_PAD
259                    .encode(Sha256::digest(envelope.id.as_str().as_bytes()))
260            )),
261            from,
262            kind: InboxKind::SessionEnvelope,
263            body: serde_json::to_value(envelope).unwrap_or(serde_json::Value::Null),
264            created_at: transport_time,
265            correlation_id: envelope
266                .correlation_id
267                .as_ref()
268                .map(|value| MsgId(value.clone())),
269        }
270    }
271
272    fn claim_generation(claim_id: &str) -> Result<u64, SessionInboxError> {
273        claim_id
274            .split_once('-')
275            .and_then(|(prefix, _)| prefix.parse::<u64>().ok())
276            .ok_or_else(|| {
277                SessionInboxError::InvalidClaim(format!(
278                    "claim filename has no ordered generation: {claim_id}"
279                ))
280            })
281    }
282
283    fn admitted_path(dir: &Path, id: &SessionMessageId) -> PathBuf {
284        // Fixed-size digest avoids an admitted tombstone filename exceeding
285        // NAME_MAX for a valid maximum-length id. The receipt body retains and
286        // verifies the original id, so a theoretical hash collision fails
287        // closed rather than silently aliasing a receipt.
288        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
289            .encode(Sha256::digest(id.as_str().as_bytes()));
290        dir.join(ADMITTED_DIR).join(format!("{encoded}.json"))
291    }
292
293    /// Digest the idempotency-defining semantics of an envelope.
294    ///
295    /// `created_at` and `attempt` are deliberately excluded: a crash retry
296    /// (notably deterministic legacy-queue migration) may reconstruct those
297    /// transport/retry attributes while still representing the same logical
298    /// delivery. Target, source, kind, body and correlation/thread edges are
299    /// immutable; reusing an id with any of those changed fails closed.
300    fn semantic_digest(envelope: &SessionMessageEnvelope) -> Result<String, SessionInboxError> {
301        let canonical = bamboo_domain::canonical_json_bytes(&envelope.idempotency_semantics());
302        Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(canonical)))
303    }
304
305    async fn quarantine_claim(
306        dir: &Path,
307        claim_path: &Path,
308        reason: &str,
309    ) -> Result<(), SessionInboxError> {
310        let name = claim_path
311            .file_name()
312            .ok_or_else(|| SessionInboxError::InvalidClaim("claim has no filename".to_string()))?;
313        let corrupt_dir = dir.join("corrupt");
314        tokio::fs::create_dir_all(&corrupt_dir)
315            .await
316            .map_err(|error| SessionInboxError::Storage(error.to_string()))?;
317        let target = corrupt_dir.join(name);
318        match tokio::fs::rename(claim_path, &target).await {
319            Ok(()) => {
320                tracing::warn!(
321                    path = %target.display(),
322                    reason,
323                    "quarantined malformed typed session inbox envelope"
324                );
325                Ok(())
326            }
327            Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
328            Err(error) => Err(SessionInboxError::Storage(format!(
329                "quarantine malformed claim {}: {error}",
330                claim_path.display()
331            ))),
332        }
333    }
334
335    /// Enumerate transport entries with a parseable ordered generation.
336    ///
337    /// A malformed `*.json` filename is neither a valid backlog item nor a
338    /// reason to poison every later delivery/claim. Move it to the durable
339    /// corruption quarantine while the inbox operation lock is held.
340    async fn valid_queue_entries(
341        dir: &Path,
342        queue: &str,
343    ) -> Result<Vec<(u64, String, PathBuf)>, SessionInboxError> {
344        let queue_dir = dir.join(queue);
345        let mut reader = match tokio::fs::read_dir(&queue_dir).await {
346            Ok(reader) => reader,
347            Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
348            Err(error) => {
349                return Err(SessionInboxError::Storage(format!(
350                    "read SessionInbox queue {}: {error}",
351                    queue_dir.display()
352                )));
353            }
354        };
355        let mut valid = Vec::new();
356        while let Some(entry) = reader.next_entry().await.map_err(|error| {
357            SessionInboxError::Storage(format!(
358                "scan SessionInbox queue {}: {error}",
359                queue_dir.display()
360            ))
361        })? {
362            let name = entry.file_name().to_string_lossy().into_owned();
363            if name.starts_with('.') || !name.ends_with(".json") {
364                continue;
365            }
366            match Self::claim_generation(&name) {
367                Ok(generation) => valid.push((generation, name, entry.path())),
368                Err(error) => {
369                    Self::quarantine_claim(dir, &entry.path(), &error.to_string()).await?;
370                }
371            }
372        }
373        Ok(valid)
374    }
375
376    #[cfg(test)]
377    async fn count_json(dir: &Path) -> Result<usize, SessionInboxError> {
378        let mut reader = match tokio::fs::read_dir(dir).await {
379            Ok(reader) => reader,
380            Err(error) if error.kind() == ErrorKind::NotFound => return Ok(0),
381            Err(error) => {
382                return Err(SessionInboxError::Storage(format!(
383                    "read inbox directory {}: {error}",
384                    dir.display()
385                )));
386            }
387        };
388        let mut count = 0;
389        while let Some(entry) = reader.next_entry().await.map_err(|error| {
390            SessionInboxError::Storage(format!("scan inbox directory {}: {error}", dir.display()))
391        })? {
392            let name = entry.file_name();
393            let name = name.to_string_lossy();
394            if !name.starts_with('.') && name.ends_with(".json") {
395                count += 1;
396            }
397        }
398        Ok(count)
399    }
400
401    /// Look up only the permanent admitted tombstone.
402    ///
403    /// This is deliberately distinct from [`existing_receipt`], which also
404    /// scans pending/claimed queues for enqueue idempotency. Ack retry may
405    /// succeed after `cur/` disappeared only with this permanent proof.
406    async fn admitted_receipt(
407        dir: &Path,
408        requested: &SessionMessageEnvelope,
409    ) -> Result<Option<SessionInboxReceipt>, SessionInboxError> {
410        let id = &requested.id;
411        let requested_digest = Self::semantic_digest(requested)?;
412        let admitted_path = Self::admitted_path(dir, id);
413        match tokio::fs::read(&admitted_path).await {
414            Ok(bytes) => {
415                let receipt: serde_json::Value = serde_json::from_slice(&bytes)
416                    .map_err(|error| SessionInboxError::Storage(error.to_string()))?;
417                let stored_id = receipt["id"].as_str().ok_or_else(|| {
418                    SessionInboxError::Storage(format!(
419                        "admitted receipt {} has no message id",
420                        admitted_path.display()
421                    ))
422                })?;
423                if stored_id != id.as_str() {
424                    return Err(SessionInboxError::InvalidClaim(format!(
425                        "admitted receipt digest collision: requested {}, stored {}",
426                        id, stored_id
427                    )));
428                }
429                let generation = receipt["generation"].as_u64().ok_or_else(|| {
430                    SessionInboxError::Storage(format!(
431                        "admitted receipt {} has no generation",
432                        admitted_path.display()
433                    ))
434                })?;
435                let stored_digest = receipt["semantic_digest"].as_str().ok_or_else(|| {
436                    SessionInboxError::InvalidClaim(format!(
437                        "admitted receipt has no semantic digest for {}",
438                        id
439                    ))
440                })?;
441                if stored_digest != requested_digest {
442                    return Err(SessionInboxError::InvalidClaim(format!(
443                        "message id {} was reused with different delivery semantics",
444                        id
445                    )));
446                }
447                return Ok(Some(SessionInboxReceipt {
448                    id: id.clone(),
449                    generation,
450                }));
451            }
452            Err(error) if error.kind() == ErrorKind::NotFound => {}
453            Err(error) => {
454                return Err(SessionInboxError::Storage(format!(
455                    "read admitted receipt {}: {error}",
456                    admitted_path.display()
457                )));
458            }
459        }
460        Ok(None)
461    }
462
463    async fn existing_receipt(
464        dir: &Path,
465        requested: &SessionMessageEnvelope,
466    ) -> Result<Option<SessionInboxReceipt>, SessionInboxError> {
467        if let Some(receipt) = Self::admitted_receipt(dir, requested).await? {
468            return Ok(Some(receipt));
469        }
470        let id = &requested.id;
471        let requested_digest = Self::semantic_digest(requested)?;
472        for queue in ["new", "cur"] {
473            for (generation, _name, path) in Self::valid_queue_entries(dir, queue).await? {
474                let Ok(bytes) = tokio::fs::read(path).await else {
475                    continue;
476                };
477                let Ok(wrapper) = serde_json::from_slice::<InboxMessage>(&bytes) else {
478                    continue;
479                };
480                if wrapper.kind != InboxKind::SessionEnvelope {
481                    continue;
482                }
483                let Ok(envelope) = serde_json::from_value::<SessionMessageEnvelope>(wrapper.body)
484                else {
485                    continue;
486                };
487                if &envelope.id == id {
488                    if Self::semantic_digest(&envelope)? != requested_digest {
489                        return Err(SessionInboxError::InvalidClaim(format!(
490                            "message id {} was reused with different delivery semantics",
491                            id
492                        )));
493                    }
494                    return Ok(Some(SessionInboxReceipt {
495                        id: id.clone(),
496                        generation,
497                    }));
498                }
499            }
500        }
501        Ok(None)
502    }
503
504    fn validate_claim_name(claim_id: &str) -> Result<(), SessionInboxError> {
505        let path = Path::new(claim_id);
506        if claim_id.is_empty()
507            || path.components().count() != 1
508            || claim_id.contains('/')
509            || claim_id.contains('\\')
510            || !claim_id.ends_with(".json")
511        {
512            return Err(SessionInboxError::InvalidClaim(claim_id.to_string()));
513        }
514        Ok(())
515    }
516}
517
518#[async_trait]
519impl SessionInboxPort for FileSessionInbox {
520    async fn deliver(
521        &self,
522        envelope: &SessionMessageEnvelope,
523    ) -> Result<SessionInboxReceipt, SessionInboxError> {
524        envelope
525            .validate()
526            .map_err(|error| SessionInboxError::Storage(error.to_string()))?;
527        let payload = serde_json::to_vec(envelope)
528            .map_err(|error| SessionInboxError::Storage(error.to_string()))?;
529        if payload.len() > self.limits.max_payload_bytes {
530            return Err(SessionInboxError::PayloadTooLarge {
531                actual: payload.len(),
532                limit: self.limits.max_payload_bytes,
533            });
534        }
535
536        let _lifecycle = self.lock_lifecycle().await?;
537        let dir = self.inbox_dir(&envelope.target_session_id).await?;
538        let _guard = self.lock_operation(&dir).await?;
539        // Enqueue idempotency is independent from consumer admission dedupe.
540        // This closes the legacy-migration crash window (deliver succeeded,
541        // source clear did not) without letting deterministic retries fill the
542        // bounded backlog.
543        if let Some(receipt) = Self::existing_receipt(&dir, envelope).await? {
544            return Ok(receipt);
545        }
546        let mailbox = Mailbox::at(&dir);
547        let current = Self::valid_queue_entries(&dir, "new").await?.len()
548            + Self::valid_queue_entries(&dir, "cur").await?.len();
549        if current >= self.limits.max_backlog {
550            return Err(SessionInboxError::BacklogFull {
551                current,
552                limit: self.limits.max_backlog,
553            });
554        }
555
556        let generation = Self::next_generation(&dir).await?;
557        mailbox
558            .deliver(&Self::wrapper(envelope, generation))
559            .await
560            .map_err(|error| SessionInboxError::Storage(error.to_string()))?;
561        Ok(SessionInboxReceipt {
562            id: envelope.id.clone(),
563            generation,
564        })
565    }
566
567    async fn mark_activation_eligible(
568        &self,
569        target_session_id: &str,
570        generation: u64,
571        policy: SessionActivationPolicy,
572    ) -> Result<(), SessionInboxError> {
573        let _lifecycle = self.lock_lifecycle().await?;
574        let dir = self.inbox_dir(target_session_id).await?;
575        let _guard = self.lock_operation(&dir).await?;
576        let delivered_generation = Self::read_generation(&dir).await?;
577        if generation == 0 || generation > delivered_generation {
578            return Err(SessionInboxError::InvalidClaim(format!(
579                "activation generation {generation} is outside delivered range 1..={delivered_generation}"
580            )));
581        }
582        // Publish the interrupt policy before the activation watermark. The
583        // two values live in separate atomic files, so a crash may expose
584        // `interrupt > activation`; it must never expose a newly eligible
585        // explicit steering prefix as RespectSpecificWait.
586        if policy == SessionActivationPolicy::InterruptSpecificWait {
587            let current_interrupt = Self::read_interrupt_generation(&dir).await?;
588            if generation > current_interrupt {
589                atomic_write(
590                    &dir.join(INTERRUPT_GENERATION_FILE),
591                    generation.to_string().as_bytes(),
592                )
593                .await
594                .map_err(|error| {
595                    SessionInboxError::Storage(format!(
596                        "persist inbox interrupt generation: {error}"
597                    ))
598                })?;
599            }
600        }
601        let current = Self::read_activation_generation(&dir).await?;
602        if generation > current {
603            atomic_write(
604                &dir.join(ACTIVATION_GENERATION_FILE),
605                generation.to_string().as_bytes(),
606            )
607            .await
608            .map_err(|error| {
609                SessionInboxError::Storage(format!("persist inbox activation generation: {error}"))
610            })?;
611        }
612        Ok(())
613    }
614
615    async fn claim(
616        &self,
617        target_session_id: &str,
618        limit: usize,
619    ) -> Result<Vec<SessionInboxClaim>, SessionInboxError> {
620        let _lifecycle = self.lock_lifecycle().await?;
621        let dir = self.inbox_dir(target_session_id).await?;
622        let _guard = self.lock_operation(&dir).await?;
623        let mailbox = Mailbox::at(&dir);
624        mailbox
625            .ensure_dirs()
626            .await
627            .map_err(|error| SessionInboxError::Storage(error.to_string()))?;
628        let activation_generation = Self::read_activation_generation(&dir).await?;
629        let limit = limit.min(self.limits.max_claim_batch);
630        if activation_generation == 0 || limit == 0 {
631            return Ok(Vec::new());
632        }
633
634        // Claim only the prefix explicitly authorized by a durable producer
635        // watermark. In particular, recovering `cur/` after a crash must not
636        // let a newer, merely staged generation hitch a ride on an older
637        // activation. Unauthorized files stay exactly where they are.
638        let mut eligible = Vec::new();
639        for queue in ["cur", "new"] {
640            for (generation, name, _) in Self::valid_queue_entries(&dir, queue).await? {
641                if generation <= activation_generation {
642                    eligible.push((generation, name, queue == "cur"));
643                }
644            }
645        }
646        eligible.sort_by(|left, right| {
647            left.0
648                .cmp(&right.0)
649                .then_with(|| left.1.cmp(&right.1))
650                // A recovered canonical claim wins over an impossible duplicate
651                // filename in `new/`.
652                .then_with(|| right.2.cmp(&left.2))
653        });
654
655        let mut claims = Vec::new();
656        for (_generation, claim_id, already_claimed) in eligible {
657            if claims.len() >= limit {
658                break;
659            }
660            let cur_path = dir.join("cur").join(&claim_id);
661            if !already_claimed {
662                let new_path = dir.join("new").join(&claim_id);
663                match tokio::fs::rename(&new_path, &cur_path).await {
664                    Ok(()) => {}
665                    Err(error) if error.kind() == ErrorKind::NotFound => continue,
666                    Err(error) => {
667                        return Err(SessionInboxError::Storage(format!(
668                            "claim SessionInbox message {}: {error}",
669                            new_path.display()
670                        )));
671                    }
672                }
673            }
674            let decoded = (|| {
675                let bytes = std::fs::read(&cur_path).map_err(|error| error.to_string())?;
676                let wrapper = serde_json::from_slice::<InboxMessage>(&bytes)
677                    .map_err(|error| error.to_string())?;
678                if wrapper.kind != InboxKind::SessionEnvelope {
679                    return Err(format!("unexpected inbox kind {:?}", wrapper.kind));
680                }
681                let generation =
682                    Self::claim_generation(&claim_id).map_err(|error| error.to_string())?;
683                let envelope = serde_json::from_value::<SessionMessageEnvelope>(wrapper.body)
684                    .map_err(|error| error.to_string())?;
685                envelope.validate().map_err(|error| error.to_string())?;
686                if envelope.target_session_id != target_session_id {
687                    return Err(format!(
688                        "claim target {} does not match inbox {target_session_id}",
689                        envelope.target_session_id
690                    ));
691                }
692                Ok(SessionInboxClaim {
693                    envelope,
694                    generation,
695                    claim_id,
696                })
697            })();
698            match decoded {
699                Ok(claim) => claims.push(claim),
700                Err(reason) => {
701                    Self::quarantine_claim(&dir, &cur_path, &reason).await?;
702                }
703            }
704        }
705        Ok(claims)
706    }
707
708    async fn was_admitted(
709        &self,
710        target_session_id: &str,
711        id: &SessionMessageId,
712    ) -> Result<bool, SessionInboxError> {
713        let _lifecycle = self.lock_lifecycle().await?;
714        let dir = self.inbox_dir(target_session_id).await?;
715        let path = Self::admitted_path(&dir, id);
716        match tokio::fs::read(&path).await {
717            Ok(bytes) => {
718                let receipt: serde_json::Value = serde_json::from_slice(&bytes)
719                    .map_err(|error| SessionInboxError::Storage(error.to_string()))?;
720                let stored_id = receipt["id"].as_str().ok_or_else(|| {
721                    SessionInboxError::Storage(format!(
722                        "admitted receipt {} has no message id",
723                        path.display()
724                    ))
725                })?;
726                if stored_id != id.as_str() {
727                    return Err(SessionInboxError::InvalidClaim(format!(
728                        "admitted receipt digest collision: requested {}, stored {}",
729                        id, stored_id
730                    )));
731                }
732                Ok(true)
733            }
734            Err(error) if error.kind() == ErrorKind::NotFound => Ok(false),
735            Err(error) => Err(SessionInboxError::Storage(format!(
736                "read admitted receipt {}: {error}",
737                path.display()
738            ))),
739        }
740    }
741
742    async fn ack(
743        &self,
744        target_session_id: &str,
745        claim: &SessionInboxClaim,
746    ) -> Result<(), SessionInboxError> {
747        Self::validate_claim_name(&claim.claim_id)?;
748        if claim.envelope.target_session_id != target_session_id {
749            return Err(SessionInboxError::InvalidClaim(
750                "claim target mismatch".to_string(),
751            ));
752        }
753        let _lifecycle = self.lock_lifecycle().await?;
754        let dir = self.inbox_dir(target_session_id).await?;
755        let _guard = self.lock_operation(&dir).await?;
756        let cur_path = dir.join("cur").join(&claim.claim_id);
757        let bytes = match tokio::fs::read(&cur_path).await {
758            Ok(bytes) => bytes,
759            Err(error) if error.kind() == ErrorKind::NotFound => {
760                // Idempotent retry only when the exact permanent receipt is
761                // already present. A missing claim without that proof is stale.
762                return match Self::admitted_receipt(&dir, &claim.envelope).await? {
763                    Some(receipt) if receipt.generation == claim.generation => Ok(()),
764                    _ => Err(SessionInboxError::InvalidClaim(format!(
765                        "canonical claim no longer exists: {}",
766                        claim.claim_id
767                    ))),
768                };
769            }
770            Err(error) => {
771                return Err(SessionInboxError::Storage(format!(
772                    "read claimed message {}: {error}",
773                    cur_path.display()
774                )));
775            }
776        };
777        let wrapper: InboxMessage = serde_json::from_slice(&bytes).map_err(|error| {
778            SessionInboxError::InvalidClaim(format!(
779                "decode canonical claim {}: {error}",
780                claim.claim_id
781            ))
782        })?;
783        if wrapper.kind != InboxKind::SessionEnvelope {
784            return Err(SessionInboxError::InvalidClaim(format!(
785                "canonical claim {} has kind {:?}",
786                claim.claim_id, wrapper.kind
787            )));
788        }
789        let persisted: SessionMessageEnvelope =
790            serde_json::from_value(wrapper.body).map_err(|error| {
791                SessionInboxError::InvalidClaim(format!(
792                    "decode canonical envelope {}: {error}",
793                    claim.claim_id
794                ))
795            })?;
796        let filename_generation = Self::claim_generation(&claim.claim_id)?;
797        if filename_generation != claim.generation
798            || persisted.id != claim.envelope.id
799            || persisted.target_session_id != target_session_id
800            || persisted != claim.envelope
801        {
802            return Err(SessionInboxError::InvalidClaim(format!(
803                "canonical claim mismatch for {}",
804                claim.claim_id
805            )));
806        }
807
808        let admitted_path = Self::admitted_path(&dir, &claim.envelope.id);
809        if let Some(existing) = Self::admitted_receipt(&dir, &claim.envelope).await? {
810            if existing.generation != claim.generation {
811                return Err(SessionInboxError::InvalidClaim(format!(
812                    "admitted receipt generation mismatch for {}",
813                    claim.envelope.id
814                )));
815            }
816        }
817        let receipt = serde_json::to_vec_pretty(&serde_json::json!({
818            "id": claim.envelope.id,
819            "generation": claim.generation,
820            "semantic_digest": Self::semantic_digest(&claim.envelope)?,
821            "admitted_at": Utc::now(),
822        }))
823        .map_err(|error| SessionInboxError::Storage(error.to_string()))?;
824        let admitted_dir = admitted_path.parent().ok_or_else(|| {
825            SessionInboxError::Storage(format!(
826                "admitted receipt has no parent: {}",
827                admitted_path.display()
828            ))
829        })?;
830        tokio::fs::create_dir_all(admitted_dir)
831            .await
832            .map_err(|error| {
833                SessionInboxError::Storage(format!(
834                    "create admitted receipt directory {}: {error}",
835                    admitted_dir.display()
836                ))
837            })?;
838        atomic_write(&admitted_path, &receipt)
839            .await
840            .map_err(|error| {
841                SessionInboxError::Storage(format!("persist admitted receipt: {error}"))
842            })?;
843
844        match tokio::fs::remove_file(&cur_path).await {
845            Ok(()) => Ok(()),
846            Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
847            Err(error) => Err(SessionInboxError::Storage(format!(
848                "remove claimed message {}: {error}",
849                cur_path.display()
850            ))),
851        }
852    }
853
854    async fn inspect(
855        &self,
856        target_session_id: &str,
857    ) -> Result<SessionInboxBacklog, SessionInboxError> {
858        let _lifecycle = self.lock_lifecycle().await?;
859        let dir = self.inbox_dir(target_session_id).await?;
860        let _guard = self.lock_operation(&dir).await?;
861        let pending = Self::valid_queue_entries(&dir, "new").await?.len();
862        let claimed = Self::valid_queue_entries(&dir, "cur").await?.len();
863        Ok(SessionInboxBacklog {
864            pending,
865            claimed,
866            generation: Self::read_generation(&dir).await?,
867            activation_generation: Self::read_activation_generation(&dir).await?,
868            interrupt_generation: Self::read_interrupt_generation(&dir).await?,
869            oldest_generation: Self::oldest_backlog_generation(&dir).await?,
870        })
871    }
872}
873
874#[cfg(test)]
875mod tests {
876    use super::*;
877    use bamboo_domain::{Session, Storage};
878    use tempfile::TempDir;
879
880    async fn fixture(
881        limits: SessionInboxLimits,
882    ) -> (TempDir, Arc<SessionStoreV2>, FileSessionInbox) {
883        let temp = TempDir::new().unwrap();
884        let sessions = Arc::new(
885            SessionStoreV2::new(temp.path().to_path_buf())
886                .await
887                .unwrap(),
888        );
889        sessions
890            .save_session(&Session::new("session-1", "model"))
891            .await
892            .unwrap();
893        let inbox = FileSessionInbox::new(sessions.clone(), limits);
894        (temp, sessions, inbox)
895    }
896
897    async fn authorize_latest(inbox: &FileSessionInbox) {
898        let generation = inbox.inspect("session-1").await.unwrap().generation;
899        inbox
900            .mark_activation_eligible(
901                "session-1",
902                generation,
903                SessionActivationPolicy::InterruptSpecificWait,
904            )
905            .await
906            .unwrap();
907    }
908
909    #[tokio::test]
910    async fn concurrent_delivery_is_ordered_and_survives_reopen() {
911        let (_temp, sessions, _fixture_inbox) = fixture(SessionInboxLimits::default()).await;
912        // Construct independent adapters over the same durable store. They do
913        // not share the runtime-owned mutex registry, so this exercises the
914        // per-session file lock that coordinates separate runtime adapters (and
915        // separate processes), not merely Arc clones of one adapter.
916        let first = Arc::new(FileSessionInbox::new(
917            sessions.clone(),
918            SessionInboxLimits::default(),
919        ));
920        let second = Arc::new(FileSessionInbox::new(
921            sessions.clone(),
922            SessionInboxLimits::default(),
923        ));
924        let mut tasks = Vec::new();
925        for index in 0..40 {
926            let inbox = if index % 2 == 0 {
927                first.clone()
928            } else {
929                second.clone()
930            };
931            tasks.push(tokio::spawn(async move {
932                let mut envelope =
933                    SessionMessageEnvelope::user_input("session-1", format!("message-{index}"));
934                envelope.id = SessionMessageId::parse(format!("id-{index}")).unwrap();
935                inbox.deliver(&envelope).await.unwrap()
936            }));
937        }
938        let mut receipts = Vec::new();
939        for task in tasks {
940            receipts.push(task.await.unwrap());
941        }
942        receipts.sort_by_key(|receipt| receipt.generation);
943        assert_eq!(
944            receipts
945                .iter()
946                .map(|receipt| receipt.generation)
947                .collect::<Vec<_>>(),
948            (1..=40).collect::<Vec<_>>()
949        );
950        second
951            .mark_activation_eligible(
952                "session-1",
953                40,
954                SessionActivationPolicy::InterruptSpecificWait,
955            )
956            .await
957            .unwrap();
958
959        let reopened = FileSessionInbox::new(sessions, SessionInboxLimits::default());
960        let claims = reopened.claim("session-1", 100).await.unwrap();
961        assert_eq!(claims.len(), 40);
962        assert_eq!(
963            claims
964                .iter()
965                .map(|claim| claim.generation)
966                .collect::<Vec<_>>(),
967            (1..=40).collect::<Vec<_>>()
968        );
969    }
970
971    #[tokio::test]
972    async fn activation_watermark_is_monotonic_and_tracks_oldest_backlog_across_reopen() {
973        let (_temp, sessions, inbox) = fixture(SessionInboxLimits::default()).await;
974        let first = SessionMessageEnvelope::user_input("session-1", "first");
975        let second = SessionMessageEnvelope::user_input("session-1", "second");
976        let first_receipt = inbox.deliver(&first).await.unwrap();
977        let second_receipt = inbox.deliver(&second).await.unwrap();
978
979        let backlog = inbox.inspect("session-1").await.unwrap();
980        assert_eq!(backlog.pending, 2);
981        assert_eq!(backlog.claimed, 0);
982        assert_eq!(backlog.oldest_generation, Some(first_receipt.generation));
983        assert_eq!(backlog.activation_generation, 0);
984        assert!(!backlog.activation_pending());
985
986        inbox
987            .mark_activation_eligible(
988                "session-1",
989                first_receipt.generation,
990                SessionActivationPolicy::RespectSpecificWait,
991            )
992            .await
993            .unwrap();
994        assert!(inbox
995            .inspect("session-1")
996            .await
997            .unwrap()
998            .activation_pending());
999
1000        // Only the authorized prefix moves to `cur`; the newer staged item
1001        // remains inert in `new/`, including across reopen.
1002        let first_claim = inbox.claim("session-1", 1).await.unwrap().remove(0);
1003        let claimed = inbox.inspect("session-1").await.unwrap();
1004        assert_eq!(claimed.pending, 1);
1005        assert_eq!(claimed.claimed, 1);
1006        assert_eq!(claimed.oldest_generation, Some(first_receipt.generation));
1007        assert!(claimed.activation_pending());
1008
1009        inbox.ack("session-1", &first_claim).await.unwrap();
1010        let after_ack = inbox.inspect("session-1").await.unwrap();
1011        assert_eq!(after_ack.pending, 1);
1012        assert_eq!(after_ack.claimed, 0);
1013        assert_eq!(after_ack.oldest_generation, Some(second_receipt.generation));
1014        assert_eq!(after_ack.activation_generation, first_receipt.generation);
1015        assert!(
1016            !after_ack.activation_pending(),
1017            "a stale activation watermark cannot wake a newer staged item"
1018        );
1019        assert!(inbox.claim("session-1", 1).await.unwrap().is_empty());
1020
1021        let reopened = FileSessionInbox::new(sessions, SessionInboxLimits::default());
1022        let reopened_backlog = reopened.inspect("session-1").await.unwrap();
1023        assert_eq!(reopened_backlog, after_ack);
1024        assert!(reopened.claim("session-1", 1).await.unwrap().is_empty());
1025
1026        // Lower/equal retries are idempotent and cannot move the watermark
1027        // backward; authorizing the newer prefix makes the remaining claim
1028        // restart-eligible.
1029        reopened
1030            .mark_activation_eligible(
1031                "session-1",
1032                first_receipt.generation,
1033                SessionActivationPolicy::RespectSpecificWait,
1034            )
1035            .await
1036            .unwrap();
1037        assert_eq!(
1038            reopened
1039                .inspect("session-1")
1040                .await
1041                .unwrap()
1042                .activation_generation,
1043            first_receipt.generation
1044        );
1045        reopened
1046            .mark_activation_eligible(
1047                "session-1",
1048                second_receipt.generation,
1049                SessionActivationPolicy::InterruptSpecificWait,
1050            )
1051            .await
1052            .unwrap();
1053        let eligible = reopened.inspect("session-1").await.unwrap();
1054        assert_eq!(eligible.activation_generation, second_receipt.generation);
1055        assert!(eligible.activation_pending());
1056        assert_eq!(eligible.interrupt_generation, second_receipt.generation);
1057        assert!(eligible.interrupt_pending());
1058        let second_claim = reopened.claim("session-1", 1).await.unwrap().remove(0);
1059        assert_eq!(second_claim.generation, second_receipt.generation);
1060        assert!(reopened
1061            .mark_activation_eligible(
1062                "session-1",
1063                second_receipt.generation + 1,
1064                SessionActivationPolicy::RespectSpecificWait,
1065            )
1066            .await
1067            .is_err());
1068    }
1069
1070    #[tokio::test]
1071    async fn interrupt_policy_is_durable_before_activation_publish_failure() {
1072        let (_temp, sessions, inbox) = fixture(SessionInboxLimits::default()).await;
1073        let receipt = inbox
1074            .deliver(&SessionMessageEnvelope::user_input(
1075                "session-1",
1076                "explicit steering",
1077            ))
1078            .await
1079            .unwrap();
1080        let dir = inbox.inbox_dir("session-1").await.unwrap();
1081        let activation_path = dir.join(ACTIVATION_GENERATION_FILE);
1082
1083        // Force the second atomic publication to fail. The first publication
1084        // (interrupt policy) must already be durable, while no readable
1085        // activation watermark is exposed to a restarting process.
1086        tokio::fs::create_dir(&activation_path).await.unwrap();
1087        assert!(inbox
1088            .mark_activation_eligible(
1089                "session-1",
1090                receipt.generation,
1091                SessionActivationPolicy::InterruptSpecificWait,
1092            )
1093            .await
1094            .is_err());
1095        assert_eq!(
1096            FileSessionInbox::read_interrupt_generation(&dir)
1097                .await
1098                .unwrap(),
1099            receipt.generation
1100        );
1101        assert!(
1102            !activation_path.is_file(),
1103            "a failed activation publish must not expose a downgraded prefix"
1104        );
1105
1106        // Restart/retry completes publication. Any visible activation prefix
1107        // now has an interrupt watermark at least as new.
1108        tokio::fs::remove_dir(&activation_path).await.unwrap();
1109        let reopened = FileSessionInbox::new(sessions, SessionInboxLimits::default());
1110        reopened
1111            .mark_activation_eligible(
1112                "session-1",
1113                receipt.generation,
1114                SessionActivationPolicy::InterruptSpecificWait,
1115            )
1116            .await
1117            .unwrap();
1118        let backlog = reopened.inspect("session-1").await.unwrap();
1119        assert_eq!(backlog.activation_generation, receipt.generation);
1120        assert!(backlog.interrupt_generation >= backlog.activation_generation);
1121        assert!(backlog.interrupt_pending());
1122    }
1123
1124    #[tokio::test]
1125    async fn admitted_receipt_is_permanent_across_restart_and_duplicate_filename() {
1126        let (_temp, sessions, inbox) = fixture(SessionInboxLimits::default()).await;
1127        let mut envelope = SessionMessageEnvelope::user_input("session-1", "first");
1128        envelope.id = SessionMessageId::parse("same-id").unwrap();
1129        inbox.deliver(&envelope).await.unwrap();
1130        authorize_latest(&inbox).await;
1131        let claim = inbox.claim("session-1", 1).await.unwrap().remove(0);
1132        inbox.ack("session-1", &claim).await.unwrap();
1133
1134        let reopened = FileSessionInbox::new(sessions, SessionInboxLimits::default());
1135        assert!(reopened
1136            .was_admitted("session-1", &envelope.id)
1137            .await
1138            .unwrap());
1139        envelope.created_at = Utc::now();
1140        let duplicate_receipt = reopened.deliver(&envelope).await.unwrap();
1141        assert_eq!(duplicate_receipt.generation, claim.generation);
1142        assert!(reopened.claim("session-1", 1).await.unwrap().is_empty());
1143        assert_eq!(reopened.inspect("session-1").await.unwrap().pending, 0);
1144    }
1145
1146    #[tokio::test]
1147    async fn repeated_delivery_is_idempotent_before_claim_and_after_ack() {
1148        let (_temp, _sessions, inbox) = fixture(SessionInboxLimits::default()).await;
1149        let mut envelope = SessionMessageEnvelope::user_input("session-1", "same");
1150        envelope.id = SessionMessageId::parse("stable-id").unwrap();
1151
1152        let first = inbox.deliver(&envelope).await.unwrap();
1153        let second = inbox.deliver(&envelope).await.unwrap();
1154        assert_eq!(first, second);
1155        let backlog = inbox.inspect("session-1").await.unwrap();
1156        assert_eq!(backlog.pending, 1);
1157        assert_eq!(backlog.generation, 1);
1158
1159        authorize_latest(&inbox).await;
1160        let claim = inbox.claim("session-1", 1).await.unwrap().remove(0);
1161        inbox.ack("session-1", &claim).await.unwrap();
1162        let third = inbox.deliver(&envelope).await.unwrap();
1163        assert_eq!(third, first);
1164        let backlog = inbox.inspect("session-1").await.unwrap();
1165        assert_eq!(backlog.pending + backlog.claimed, 0);
1166        assert_eq!(backlog.generation, 1);
1167    }
1168
1169    #[tokio::test]
1170    async fn reordered_nested_json_uses_the_same_semantic_receipt() {
1171        let (_temp, _sessions, inbox) = fixture(SessionInboxLimits::default()).await;
1172        let mut first_inner = serde_json::Map::new();
1173        first_inner.insert("z".to_string(), serde_json::json!({"b": 2, "a": 1}));
1174        first_inner.insert("a".to_string(), serde_json::json!([{"y": 2, "x": 1}]));
1175        let mut second_nested = serde_json::Map::new();
1176        second_nested.insert("x".to_string(), serde_json::json!(1));
1177        second_nested.insert("y".to_string(), serde_json::json!(2));
1178        let mut second_inner = serde_json::Map::new();
1179        second_inner.insert(
1180            "a".to_string(),
1181            serde_json::Value::Array(vec![serde_json::Value::Object(second_nested)]),
1182        );
1183        second_inner.insert("z".to_string(), serde_json::json!({"a": 1, "b": 2}));
1184
1185        let make = |data| SessionMessageEnvelope {
1186            id: SessionMessageId::parse("nested-canonical-id").unwrap(),
1187            source: SessionMessageSource::Runtime {
1188                subsystem: "canonical-test".to_string(),
1189            },
1190            target_session_id: "session-1".to_string(),
1191            kind: bamboo_domain::SessionMessageKind::RuntimeInstruction,
1192            body: bamboo_domain::SessionMessageBody::RuntimeInstruction(
1193                bamboo_domain::SessionRuntimeInstruction {
1194                    instruction: "nested".to_string(),
1195                    content: None,
1196                    data: Some(data),
1197                    provider_message: None,
1198                },
1199            ),
1200            created_at: Utc::now(),
1201            thread_id: None,
1202            in_reply_to: None,
1203            attempt: None,
1204            correlation_id: Some("canonical-json".to_string()),
1205        };
1206        let first = make(serde_json::Value::Object(first_inner));
1207        let mut second = make(serde_json::Value::Object(second_inner));
1208        second.created_at = Utc::now();
1209        second.attempt = Some(9);
1210
1211        let first_receipt = inbox.deliver(&first).await.unwrap();
1212        let retry_receipt = inbox.deliver(&second).await.unwrap();
1213        assert_eq!(retry_receipt, first_receipt);
1214        let backlog = inbox.inspect("session-1").await.unwrap();
1215        assert_eq!(backlog.generation, 1);
1216        assert_eq!(backlog.pending, 1);
1217    }
1218
1219    #[tokio::test]
1220    async fn reused_id_with_changed_semantics_fails_before_claim_and_after_ack_restart() {
1221        let (_temp, sessions, inbox) = fixture(SessionInboxLimits::default()).await;
1222        let mut original = SessionMessageEnvelope::user_input("session-1", "original");
1223        original.id = SessionMessageId::parse("semantic-id").unwrap();
1224        inbox.deliver(&original).await.unwrap();
1225
1226        let mut changed = original.clone();
1227        changed.body = bamboo_domain::SessionMessageBody::Content(
1228            bamboo_domain::SessionMessageContent::text("different"),
1229        );
1230        assert!(matches!(
1231            inbox.deliver(&changed).await,
1232            Err(SessionInboxError::InvalidClaim(_))
1233        ));
1234        assert_eq!(inbox.inspect("session-1").await.unwrap().pending, 1);
1235
1236        authorize_latest(&inbox).await;
1237        let claim = inbox.claim("session-1", 1).await.unwrap().remove(0);
1238        inbox.ack("session-1", &claim).await.unwrap();
1239        let reopened = FileSessionInbox::new(sessions, SessionInboxLimits::default());
1240        assert!(matches!(
1241            reopened.deliver(&changed).await,
1242            Err(SessionInboxError::InvalidClaim(_))
1243        ));
1244        assert_eq!(
1245            reopened.inspect("session-1").await.unwrap().pending
1246                + reopened.inspect("session-1").await.unwrap().claimed,
1247            0
1248        );
1249    }
1250
1251    #[tokio::test]
1252    async fn deterministic_legacy_retry_ignores_only_retry_metadata() {
1253        let (_temp, sessions, inbox) = fixture(SessionInboxLimits::default()).await;
1254        let value = serde_json::json!({"content": "legacy retry"});
1255        let mut original = SessionMessageEnvelope {
1256            id: SessionMessageId::legacy("session-1", 0, &value),
1257            source: SessionMessageSource::Runtime {
1258                subsystem: "legacy_pending_injected_messages".to_string(),
1259            },
1260            target_session_id: "session-1".to_string(),
1261            kind: bamboo_domain::SessionMessageKind::RuntimeInstruction,
1262            body: bamboo_domain::SessionMessageBody::RuntimeInstruction(
1263                bamboo_domain::SessionRuntimeInstruction {
1264                    instruction: "legacy_pending_injected_message".to_string(),
1265                    content: Some(bamboo_domain::SessionMessageContent::text("legacy retry")),
1266                    data: Some(value),
1267                    provider_message: None,
1268                },
1269            ),
1270            created_at: Utc::now(),
1271            thread_id: None,
1272            in_reply_to: None,
1273            attempt: None,
1274            correlation_id: Some("legacy_pending_injected_messages".to_string()),
1275        };
1276        let first = inbox.deliver(&original).await.unwrap();
1277        original.created_at += chrono::Duration::seconds(5);
1278        original.attempt = Some(2);
1279        assert_eq!(inbox.deliver(&original).await.unwrap(), first);
1280
1281        authorize_latest(&inbox).await;
1282        let claim = inbox.claim("session-1", 1).await.unwrap().remove(0);
1283        inbox.ack("session-1", &claim).await.unwrap();
1284        let reopened = FileSessionInbox::new(sessions, SessionInboxLimits::default());
1285        original.created_at += chrono::Duration::seconds(5);
1286        original.attempt = Some(3);
1287        assert_eq!(reopened.deliver(&original).await.unwrap(), first);
1288    }
1289
1290    #[tokio::test]
1291    async fn maximum_length_id_uses_bounded_transport_and_receipt_names() {
1292        let (_temp, _sessions, inbox) = fixture(SessionInboxLimits::default()).await;
1293        let mut envelope = SessionMessageEnvelope::user_input("session-1", "long id");
1294        envelope.id = SessionMessageId::parse("x".repeat(256)).unwrap();
1295        inbox.deliver(&envelope).await.unwrap();
1296        authorize_latest(&inbox).await;
1297        let claim = inbox.claim("session-1", 1).await.unwrap().remove(0);
1298        inbox.ack("session-1", &claim).await.unwrap();
1299        assert!(inbox.was_admitted("session-1", &envelope.id).await.unwrap());
1300    }
1301
1302    #[tokio::test]
1303    async fn admitted_digest_mismatch_fails_closed() {
1304        let (_temp, _sessions, inbox) = fixture(SessionInboxLimits::default()).await;
1305        let requested = SessionMessageId::parse("requested").unwrap();
1306        let dir = inbox.inbox_dir("session-1").await.unwrap();
1307        let path = FileSessionInbox::admitted_path(&dir, &requested);
1308        tokio::fs::create_dir_all(path.parent().unwrap())
1309            .await
1310            .unwrap();
1311        atomic_write(
1312            &path,
1313            serde_json::to_vec(&serde_json::json!({
1314                "id": "different",
1315                "generation": 1,
1316            }))
1317            .unwrap()
1318            .as_slice(),
1319        )
1320        .await
1321        .unwrap();
1322
1323        assert!(matches!(
1324            inbox.was_admitted("session-1", &requested).await,
1325            Err(SessionInboxError::InvalidClaim(_))
1326        ));
1327        let mut envelope = SessionMessageEnvelope::user_input("session-1", "collision");
1328        envelope.id = requested;
1329        assert!(matches!(
1330            inbox.deliver(&envelope).await,
1331            Err(SessionInboxError::InvalidClaim(_))
1332        ));
1333    }
1334
1335    #[tokio::test]
1336    async fn ack_rejects_mismatched_id_and_generation_without_deleting_claim() {
1337        let (_temp, _sessions, inbox) = fixture(SessionInboxLimits::default()).await;
1338        let mut envelope = SessionMessageEnvelope::user_input("session-1", "exact");
1339        envelope.id = SessionMessageId::parse("exact-id").unwrap();
1340        inbox.deliver(&envelope).await.unwrap();
1341        authorize_latest(&inbox).await;
1342        let claim = inbox.claim("session-1", 1).await.unwrap().remove(0);
1343
1344        let mut wrong_generation = claim.clone();
1345        wrong_generation.generation += 1;
1346        assert!(matches!(
1347            inbox.ack("session-1", &wrong_generation).await,
1348            Err(SessionInboxError::InvalidClaim(_))
1349        ));
1350
1351        let mut wrong_id = claim.clone();
1352        wrong_id.envelope.id = SessionMessageId::parse("other-id").unwrap();
1353        assert!(matches!(
1354            inbox.ack("session-1", &wrong_id).await,
1355            Err(SessionInboxError::InvalidClaim(_))
1356        ));
1357        let backlog = inbox.inspect("session-1").await.unwrap();
1358        assert_eq!(backlog.claimed, 1);
1359        assert!(!inbox.was_admitted("session-1", &envelope.id).await.unwrap());
1360
1361        inbox.ack("session-1", &claim).await.unwrap();
1362        assert!(inbox.was_admitted("session-1", &envelope.id).await.unwrap());
1363    }
1364
1365    #[tokio::test]
1366    async fn ack_cannot_treat_matching_unclaimed_new_entry_as_permanent_proof() {
1367        let (_temp, _sessions, inbox) = fixture(SessionInboxLimits::default()).await;
1368        let mut envelope = SessionMessageEnvelope::user_input("session-1", "still pending");
1369        envelope.id = SessionMessageId::parse("pending-is-not-admitted").unwrap();
1370        let receipt = inbox.deliver(&envelope).await.unwrap();
1371        let dir = inbox.inbox_dir("session-1").await.unwrap();
1372        let mut entries = tokio::fs::read_dir(dir.join("new")).await.unwrap();
1373        let claim_id = loop {
1374            let entry = entries.next_entry().await.unwrap().unwrap();
1375            let name = entry.file_name().to_string_lossy().into_owned();
1376            if name.ends_with(".json") && !name.starts_with('.') {
1377                break name;
1378            }
1379        };
1380        let fabricated = SessionInboxClaim {
1381            envelope: envelope.clone(),
1382            generation: receipt.generation,
1383            claim_id,
1384        };
1385
1386        assert!(matches!(
1387            inbox.ack("session-1", &fabricated).await,
1388            Err(SessionInboxError::InvalidClaim(_))
1389        ));
1390        let backlog = inbox.inspect("session-1").await.unwrap();
1391        assert_eq!(backlog.pending, 1);
1392        assert_eq!(backlog.claimed, 0);
1393        assert!(!inbox.was_admitted("session-1", &envelope.id).await.unwrap());
1394    }
1395
1396    #[tokio::test]
1397    async fn ack_completes_receipt_then_remove_crash_window_idempotently() {
1398        let (_temp, _sessions, inbox) = fixture(SessionInboxLimits::default()).await;
1399        let mut envelope = SessionMessageEnvelope::user_input("session-1", "exactly once");
1400        envelope.id = SessionMessageId::parse("receipt-before-remove").unwrap();
1401        inbox.deliver(&envelope).await.unwrap();
1402        authorize_latest(&inbox).await;
1403        let claim = inbox.claim("session-1", 1).await.unwrap().remove(0);
1404
1405        // Simulate a crash after the permanent receipt was committed but before
1406        // the canonical cur file was removed.
1407        let dir = inbox.inbox_dir("session-1").await.unwrap();
1408        let admitted_path = FileSessionInbox::admitted_path(&dir, &envelope.id);
1409        tokio::fs::create_dir_all(admitted_path.parent().unwrap())
1410            .await
1411            .unwrap();
1412        let receipt = serde_json::to_vec(&serde_json::json!({
1413            "id": envelope.id,
1414            "generation": claim.generation,
1415            "semantic_digest": FileSessionInbox::semantic_digest(&envelope).unwrap(),
1416            "admitted_at": Utc::now(),
1417        }))
1418        .unwrap();
1419        atomic_write(&admitted_path, &receipt).await.unwrap();
1420        assert_eq!(inbox.inspect("session-1").await.unwrap().claimed, 1);
1421
1422        inbox.ack("session-1", &claim).await.unwrap();
1423        assert_eq!(inbox.inspect("session-1").await.unwrap().claimed, 0);
1424        assert!(inbox.was_admitted("session-1", &envelope.id).await.unwrap());
1425        // Retrying after both steps completed is also an exact no-op.
1426        inbox.ack("session-1", &claim).await.unwrap();
1427    }
1428
1429    #[tokio::test]
1430    async fn malformed_typed_envelope_is_quarantined_without_poisoning_drain() {
1431        let (_temp, _sessions, inbox) = fixture(SessionInboxLimits::default()).await;
1432        let dir = inbox.inbox_dir("session-1").await.unwrap();
1433        let mailbox = Mailbox::at(&dir);
1434        mailbox
1435            .deliver(&InboxMessage {
1436                id: MsgId("malformed".to_string()),
1437                from: AgentRef {
1438                    session_id: "runtime:test".to_string(),
1439                    role: None,
1440                },
1441                kind: InboxKind::SessionEnvelope,
1442                body: serde_json::json!({"not": "an envelope"}),
1443                created_at: Utc.timestamp_nanos(0),
1444                correlation_id: None,
1445            })
1446            .await
1447            .unwrap();
1448        let valid = SessionMessageEnvelope::user_input("session-1", "valid");
1449        inbox.deliver(&valid).await.unwrap();
1450        authorize_latest(&inbox).await;
1451
1452        let claims = inbox.claim("session-1", 10).await.unwrap();
1453        assert_eq!(claims.len(), 1);
1454        assert_eq!(claims[0].envelope.id, valid.id);
1455        assert_eq!(
1456            FileSessionInbox::count_json(&dir.join("corrupt"))
1457                .await
1458                .unwrap(),
1459            1
1460        );
1461    }
1462
1463    #[tokio::test]
1464    async fn malformed_json_filename_is_quarantined_without_blocking_backlog_or_claim() {
1465        let (_temp, _sessions, inbox) = fixture(SessionInboxLimits {
1466            max_payload_bytes: 256 * 1024,
1467            max_backlog: 1,
1468            max_claim_batch: 128,
1469        })
1470        .await;
1471        let dir = inbox.inbox_dir("session-1").await.unwrap();
1472        let new_dir = dir.join("new");
1473        tokio::fs::create_dir_all(&new_dir).await.unwrap();
1474        tokio::fs::write(new_dir.join("not-a-generation.json"), b"{}")
1475            .await
1476            .unwrap();
1477
1478        // Inspection quarantines the malformed transport artifact, so it is
1479        // neither the oldest generation nor a capacity-consuming message.
1480        let empty = inbox.inspect("session-1").await.unwrap();
1481        assert_eq!(empty.pending + empty.claimed, 0);
1482        assert_eq!(empty.oldest_generation, None);
1483        let valid = SessionMessageEnvelope::user_input("session-1", "valid after poison");
1484        let receipt = inbox.deliver(&valid).await.unwrap();
1485        inbox
1486            .mark_activation_eligible(
1487                "session-1",
1488                receipt.generation,
1489                SessionActivationPolicy::InterruptSpecificWait,
1490            )
1491            .await
1492            .unwrap();
1493        let claims = inbox.claim("session-1", 10).await.unwrap();
1494        assert_eq!(claims.len(), 1);
1495        assert_eq!(claims[0].envelope.id, valid.id);
1496        assert_eq!(
1497            FileSessionInbox::count_json(&dir.join("corrupt"))
1498                .await
1499                .unwrap(),
1500            1
1501        );
1502    }
1503
1504    #[tokio::test]
1505    async fn payload_and_backlog_limits_are_explicit() {
1506        let limits = SessionInboxLimits {
1507            max_payload_bytes: 512,
1508            max_backlog: 1,
1509            max_claim_batch: 1,
1510        };
1511        let (_temp, _sessions, inbox) = fixture(limits).await;
1512        let oversized = SessionMessageEnvelope::user_input("session-1", "x".repeat(1024));
1513        assert!(matches!(
1514            inbox.deliver(&oversized).await,
1515            Err(SessionInboxError::PayloadTooLarge { .. })
1516        ));
1517        inbox
1518            .deliver(&SessionMessageEnvelope::user_input("session-1", "one"))
1519            .await
1520            .unwrap();
1521        assert!(matches!(
1522            inbox
1523                .deliver(&SessionMessageEnvelope::user_input("session-1", "two"))
1524                .await,
1525            Err(SessionInboxError::BacklogFull {
1526                current: 1,
1527                limit: 1
1528            })
1529        ));
1530    }
1531}