Skip to main content

lash_core/
attachments.rs

1mod file_store;
2
3pub use file_store::FileAttachmentStore;
4
5use std::collections::{BTreeSet, HashMap};
6use std::path::PathBuf;
7use std::sync::{Arc, Mutex};
8
9use lash_sansio::{AttachmentCreateMeta, AttachmentId, AttachmentMeta, AttachmentRef};
10use sha2::{Digest, Sha256};
11
12use crate::store::{AttachmentIntent, AttachmentManifest, StoreError};
13
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub enum AttachmentProducer {
16    Host,
17    TurnIngress,
18    Tool { tool_name: String },
19}
20
21#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
22#[error("attachment source policy denied {producer:?}: {reason}")]
23pub struct AttachmentSourcePolicyError {
24    pub producer: AttachmentProducer,
25    pub reason: String,
26}
27
28pub trait AttachmentSourcePolicy: Send + Sync {
29    fn authorize(
30        &self,
31        producer: &AttachmentProducer,
32        source: &crate::AttachmentSource,
33    ) -> Result<(), AttachmentSourcePolicyError>;
34}
35
36#[derive(Debug, Default)]
37pub struct OpenAttachmentSourcePolicy;
38
39impl AttachmentSourcePolicy for OpenAttachmentSourcePolicy {
40    fn authorize(
41        &self,
42        _producer: &AttachmentProducer,
43        _source: &crate::AttachmentSource,
44    ) -> Result<(), AttachmentSourcePolicyError> {
45        Ok(())
46    }
47}
48
49#[derive(Debug, thiserror::Error)]
50pub enum AttachmentStoreError {
51    #[error("attachment `{0}` was not found")]
52    NotFound(AttachmentId),
53    #[error("attachment store I/O failed at {path}: {source}")]
54    Io {
55        path: PathBuf,
56        #[source]
57        source: std::io::Error,
58    },
59    #[error("attachment manifest write failed: {0}")]
60    ManifestRecordFailed(String),
61    #[error("attachment store backend failed: {0}")]
62    Backend(String),
63}
64
65#[derive(Clone, Debug)]
66pub struct StoredAttachment {
67    pub bytes: Vec<u8>,
68}
69
70/// One blob enumerated by [`AttachmentStore::list`]. Feeds mark-and-sweep GC:
71/// the sweeper pairs each blob's `id` against the live root set and uses
72/// `last_modified_epoch_ms` to apply the write grace period. Backends that
73/// cannot report a modification time leave it `None`, and the sweep treats
74/// such blobs as always past the grace window.
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub struct StoredBlobRef {
77    pub id: AttachmentId,
78    pub last_modified_epoch_ms: Option<u64>,
79}
80
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
82pub enum AttachmentStorePersistence {
83    Ephemeral,
84    Durable,
85}
86
87impl AttachmentStorePersistence {
88    /// Map the attachment-store persistence signal onto the shared
89    /// [`DurabilityTier`](crate::DurabilityTier): `Ephemeral -> Inline`,
90    /// `Durable -> Durable`. Lets consistency checks read every wired store's
91    /// tier uniformly without a separate `durability_tier()` method here.
92    pub fn durability_tier(self) -> crate::DurabilityTier {
93        match self {
94            Self::Ephemeral => crate::DurabilityTier::Inline,
95            Self::Durable => crate::DurabilityTier::Durable,
96        }
97    }
98}
99
100/// A flat, content-addressed blob store: host-supplied dumb infrastructure.
101///
102/// The store maps a content hash to its bytes and nothing more. It has no
103/// notion of sessions — identical bytes written by any number of sessions
104/// resolve to one physical blob, and that dedup is intended. Reference
105/// tracking and the session boundary live one layer up in
106/// [`SessionAttachmentStore`] and the [`AttachmentManifest`]; lifecycle
107/// (which blobs may be deleted) lives above that in the host, via
108/// [`reclaim_unreferenced_attachments`].
109///
110/// Conventions every backend upholds: `put` is idempotent (identical bytes are
111/// a no-op returning the same ref), `delete` is idempotent, and a missing blob
112/// maps to [`AttachmentStoreError::NotFound`].
113#[async_trait::async_trait]
114pub trait AttachmentStore: Send + Sync {
115    fn persistence(&self) -> AttachmentStorePersistence {
116        AttachmentStorePersistence::Ephemeral
117    }
118
119    async fn put(
120        &self,
121        bytes: Vec<u8>,
122        meta: AttachmentCreateMeta,
123    ) -> Result<AttachmentRef, AttachmentStoreError>;
124
125    async fn get(&self, id: &AttachmentId) -> Result<StoredAttachment, AttachmentStoreError>;
126
127    /// Remove one blob. Idempotent: deleting an absent blob is a no-op. This is
128    /// the primitive mark-and-sweep GC uses to reclaim unreferenced content;
129    /// per-session lifecycle is expressed by dropping manifest refs, never by
130    /// calling this directly for a live session.
131    async fn delete(&self, id: &AttachmentId) -> Result<(), AttachmentStoreError>;
132
133    /// Enumerate every blob currently held. Used only by mark-and-sweep GC.
134    /// Large deployments may hold many blobs; backends should stream/batch
135    /// internally where possible. Order is unspecified.
136    async fn list(&self) -> Result<Vec<StoredBlobRef>, AttachmentStoreError>;
137
138    /// Re-fetch one blob's current freshness signal, or `None` if it is absent.
139    ///
140    /// The mark-and-sweep GC calls this immediately before deleting a candidate:
141    /// the `last_modified_epoch_ms` captured by the `list` snapshot is stale by
142    /// delete time, so a blob that a fresh `put` (a new intent for the same
143    /// content id) touched *after* the snapshot must be spared. The default
144    /// implementation scans `list`; backends override it with a cheap
145    /// stat/`HEAD`.
146    async fn head(&self, id: &AttachmentId) -> Result<Option<StoredBlobRef>, AttachmentStoreError> {
147        Ok(self.list().await?.into_iter().find(|blob| &blob.id == id))
148    }
149}
150
151/// A source of the live attachment root set across every session a store
152/// factory owns. Committed refs and intents with owners that can still commit
153/// are roots; terminal-owner intents remain roots through their retention
154/// window. Unscoped host puts use the legacy age-only fallback.
155///
156/// Implemented by session-store factories, which own the full set of sessions:
157/// a global manifest table answers in one query (Postgres); a per-session
158/// database topology answers by iterating the factory's session databases at
159/// sweep time (SQLite); an in-memory factory answers from its live stores.
160#[async_trait::async_trait]
161pub trait AttachmentRootSet: Send + Sync {
162    /// The live root set, reconciled against `intent_grace_cutoff_epoch_ms`.
163    ///
164    /// A committed ref is always a root. An uncommitted intent remains a root
165    /// until both the cutoff has elapsed and its durable owner is proven unable
166    /// to commit. A turn owner is dead only after a superseding turn commit for
167    /// the session; a process owner is dead only after its durable process row
168    /// is pruned. An ownerless host intent retains the legacy age-only rule.
169    async fn live_attachment_refs(
170        &self,
171        intent_grace_cutoff_epoch_ms: u64,
172    ) -> Result<BTreeSet<AttachmentId>, StoreError>;
173
174    /// Whether a single id currently has a live root under the same age plus
175    /// owner-reachability rule as [`Self::live_attachment_refs`].
176    ///
177    /// Targeted counterpart to [`Self::live_attachment_refs`] for the GC lever's
178    /// delete-time root re-check (see [`reclaim_unreferenced_attachments`]): the
179    /// full root set is snapshotted once, but a candidate blob can be re-referenced
180    /// in the narrow window between the freshness re-check and the delete, so the
181    /// sweep re-probes just that id. Unlike the snapshot, this is a read-only probe
182    /// — it must NOT reconcile (forget) aged intents. Backends answer with a single
183    /// indexed query / first-hit scan rather than materializing the whole set. The
184    /// default re-materializes the root set and tests membership.
185    async fn has_live_attachment_ref(
186        &self,
187        id: &AttachmentId,
188        intent_grace_cutoff_epoch_ms: u64,
189    ) -> Result<bool, StoreError> {
190        Ok(self
191            .live_attachment_refs(intent_grace_cutoff_epoch_ms)
192            .await?
193            .contains(id))
194    }
195}
196
197/// Every session-store factory is a root set: it owns all sessions, so it can
198/// enumerate their refs. The concrete factories override
199/// [`SessionStoreFactory::live_attachment_refs`](crate::SessionStoreFactory::live_attachment_refs);
200/// this blanket makes any of them (including `dyn SessionStoreFactory`) usable
201/// as the GC lever's `root_set`.
202#[async_trait::async_trait]
203impl<T: crate::SessionStoreFactory + ?Sized> AttachmentRootSet for T {
204    async fn live_attachment_refs(
205        &self,
206        intent_grace_cutoff_epoch_ms: u64,
207    ) -> Result<BTreeSet<AttachmentId>, StoreError> {
208        crate::SessionStoreFactory::live_attachment_refs(self, intent_grace_cutoff_epoch_ms).await
209    }
210
211    async fn has_live_attachment_ref(
212        &self,
213        id: &AttachmentId,
214        intent_grace_cutoff_epoch_ms: u64,
215    ) -> Result<bool, StoreError> {
216        crate::SessionStoreFactory::has_live_attachment_ref(self, id, intent_grace_cutoff_epoch_ms)
217            .await
218    }
219}
220
221/// Outcome of a host-invoked unreferenced-attachment reclamation sweep.
222///
223/// See [`reclaim_unreferenced_attachments`] for the full contract. Returned so
224/// hosts can emit metrics the same way [`GcReport`](crate::GcReport) and
225/// [`VacuumReport`](crate::VacuumReport) do for the store-side levers.
226#[derive(Clone, Debug, Default, PartialEq, Eq)]
227pub struct AttachmentReclamationReport {
228    /// Blobs enumerated from the backend and considered by the sweep.
229    pub scanned_blob_count: usize,
230    /// Blobs deleted: unreferenced by any session and past the grace window.
231    pub reclaimed_count: usize,
232    /// Blobs the sweep tried but failed to delete. The sweep continues past
233    /// per-blob failures and reports them here rather than aborting.
234    pub failed_ids: Vec<AttachmentId>,
235    /// Blobs that were deleted but a live root re-appeared for in the residual
236    /// window between the pre-delete root re-check and the delete itself. The
237    /// bytes are already gone and cannot be restored, but a put-always-writes
238    /// backend self-heals on the referencing session's next `put` (the intent's
239    /// write-ahead ordering guarantees a retry rewrites the bytes). Recorded and
240    /// logged at error level so an operator sees the (single-digit-millisecond,
241    /// self-healing) event rather than a silent data loss.
242    pub deleted_while_referenced: Vec<AttachmentId>,
243}
244
245/// Mark-and-sweep GC for attachment blobs — the host-invocable counterpart to
246/// [`StoreMaintenance::gc_unreachable`](crate::StoreMaintenance::gc_unreachable)
247/// for attachment payloads.
248///
249/// Enumerates every blob in `backend`, computes the live root set from
250/// `root_set` (committed refs plus intents whose durable owners can still
251/// commit), and deletes every blob no session references. A `grace_period_ms`
252/// retention window delays reclamation after owner death and protects freshly
253/// written blobs even if they currently look unreferenced. Per-blob delete
254/// failures are collected into
255/// [`AttachmentReclamationReport::failed_ids`]; the sweep does not abort on the
256/// first failure.
257///
258/// # Two reconciliation windows, one grace period
259///
260/// `grace_period_ms` gates two independent hazards, both keyed off the same
261/// value:
262///
263/// * *Terminal-owner retention.* An uncommitted intent older than
264///   `now - grace_period_ms` is forgotten only when its turn has been
265///   superseded, its process row has been pruned, or it has no durable owner.
266///   Age never proves a turn or process dead.
267/// * *Delete-time freshness race.* The `list` snapshot's `last_modified` is
268///   stale by the time the sweep reaches a candidate. Before deleting, the sweep
269///   re-fetches the blob's freshness with [`AttachmentStore::head`] and spares
270///   any blob touched within the window — covering the interleaving where a new
271///   intent plus a `put` of the same content id lands after the root snapshot
272///   was taken (the `put` refreshes the blob's modification time).
273///
274/// # The delete window and its residual
275///
276/// After the freshness re-check the sweep does a *targeted root re-check* for the
277/// single candidate id ([`AttachmentRootSet::has_live_attachment_ref`]) and skips
278/// any blob a session has re-referenced since the root snapshot. This probe is
279/// what the write-ahead intent ordering makes reliable: the facade records the
280/// manifest intent *before* the backend `put`, so a root exists no later than the
281/// bytes. A ref can still appear in the residual window between that probe and the
282/// physical delete — bounded to the single-digit milliseconds of one probe plus
283/// one delete. When it does, the bytes are already unrecoverable, but every
284/// backend `put` physically rewrites absent content, so the referencing session's
285/// next `put` self-heals; the sweep records the id in
286/// [`AttachmentReclamationReport::deleted_while_referenced`] and logs at error
287/// level so the (rare, self-healing) event is never silent.
288///
289/// # Deployment assumption
290///
291/// The `backend` instance is assumed exclusive to this lash deployment: every
292/// blob it holds was written by this deployment's sessions, so a blob with no
293/// live ref is genuinely garbage. Sharing a bucket/directory across
294/// deployments would let this sweep delete another deployment's live content.
295///
296/// # Policy is the host's (ADR-0014)
297///
298/// This is a lever, not a scheduler: the host chooses `grace_period_ms` as a
299/// post-terminal retention policy and chooses when to run it. The window is not
300/// a correctness bound on replay duration. The lever does no background work.
301pub async fn reclaim_unreferenced_attachments<R>(
302    root_set: &R,
303    backend: &dyn AttachmentStore,
304    grace_period_ms: u64,
305) -> Result<AttachmentReclamationReport, AttachmentStoreError>
306where
307    R: AttachmentRootSet + ?Sized,
308{
309    let now = now_epoch_ms();
310    let intent_grace_cutoff = now.saturating_sub(grace_period_ms);
311    let live = root_set
312        .live_attachment_refs(intent_grace_cutoff)
313        .await
314        .map_err(|err| {
315            AttachmentStoreError::Backend(format!(
316                "failed to enumerate live attachment refs: {err}"
317            ))
318        })?;
319    let blobs = backend.list().await?;
320    let mut report = AttachmentReclamationReport::default();
321    for blob in blobs {
322        report.scanned_blob_count += 1;
323        if live.contains(&blob.id) {
324            continue;
325        }
326        if within_grace(blob.last_modified_epoch_ms, now, grace_period_ms) {
327            // Fresh write or in-flight intent per the (possibly stale) snapshot.
328            continue;
329        }
330        // (a) Delete-time freshness re-check: the snapshot's freshness is stale, so
331        // re-stat the blob immediately before deleting. A concurrent
332        // new-intent-plus-`put` of the same content id — landed after the root
333        // snapshot — refreshes the blob's modification time; spare it so a
334        // newly-referenced blob is never reclaimed out from under its intent.
335        match backend.head(&blob.id).await {
336            Ok(Some(fresh)) => {
337                if within_grace(
338                    fresh.last_modified_epoch_ms,
339                    now_epoch_ms(),
340                    grace_period_ms,
341                ) {
342                    continue;
343                }
344            }
345            // Already gone (a concurrent delete): nothing to reclaim.
346            Ok(None) => continue,
347            // Could not re-stat: treat as a per-blob failure rather than risk
348            // deleting a blob we can no longer vouch for.
349            Err(_) => {
350                report.failed_ids.push(blob.id);
351                continue;
352            }
353        }
354        // (b) Targeted root re-check for THIS id. The `live` snapshot was taken
355        // before the per-blob loop began; a session may have recorded a fresh
356        // intent for this content id since. This is effective because the facade's
357        // `put` records the write-ahead intent BEFORE the backend `put` refreshes
358        // the bytes (`SessionAttachmentStore::put`): by the time bytes exist to be
359        // reclaimed, the intent row that roots them already does, so this probe
360        // observes it. A live root here means we must not delete.
361        match root_set
362            .has_live_attachment_ref(&blob.id, intent_grace_cutoff)
363            .await
364        {
365            Ok(true) => continue,
366            Ok(false) => {}
367            // Could not probe the root set: do not delete a blob we can no longer
368            // prove is unreferenced.
369            Err(_) => {
370                report.failed_ids.push(blob.id);
371                continue;
372            }
373        }
374        // (c) Delete.
375        match backend.delete(&blob.id).await {
376            Ok(()) => {
377                report.reclaimed_count += 1;
378                // (d) Post-delete root re-check. A ref can still appear in the
379                // residual window between (b) and (c) — bounded to the single-digit
380                // milliseconds of one root-set probe plus one backend delete. The
381                // bytes are already gone and cannot be restored, but every backend
382                // `put` physically rewrites content when it is absent (file store
383                // rewrites on a missing path, S3 PUTs unconditionally, the
384                // in-memory store re-inserts), so the referencing session's next
385                // `put` self-heals. Record and log loudly so an operator sees the
386                // (rare, self-healing) event.
387                // A late ref (probe answers true) is recorded and alarmed. No late
388                // ref, or a failed probe, needs nothing more — a failed probe here
389                // cannot un-delete the blob.
390                if let Ok(true) = root_set
391                    .has_live_attachment_ref(&blob.id, intent_grace_cutoff)
392                    .await
393                {
394                    tracing::error!(
395                        attachment_id = %blob.id,
396                        "attachment GC deleted a blob that was re-referenced in the \
397                         delete window; bytes are unrecoverable but a subsequent put \
398                         self-heals"
399                    );
400                    report.deleted_while_referenced.push(blob.id);
401                }
402            }
403            Err(_) => report.failed_ids.push(blob.id),
404        }
405    }
406    Ok(report)
407}
408
409/// Whether a blob modified at `last_modified_epoch_ms` is within the write grace
410/// window relative to `now`. A backend that cannot report a modification time
411/// (`None`) is treated as past the window, matching [`StoredBlobRef`].
412fn within_grace(last_modified_epoch_ms: Option<u64>, now: u64, grace_period_ms: u64) -> bool {
413    last_modified_epoch_ms.is_some_and(|modified| now.saturating_sub(modified) < grace_period_ms)
414}
415
416struct InMemoryBlob {
417    stored: StoredAttachment,
418    stored_at_epoch_ms: u64,
419}
420
421#[derive(Default)]
422pub struct InMemoryAttachmentStore {
423    attachments: Mutex<HashMap<AttachmentId, InMemoryBlob>>,
424}
425
426impl InMemoryAttachmentStore {
427    pub fn new() -> Self {
428        Self::default()
429    }
430}
431
432#[async_trait::async_trait]
433impl AttachmentStore for InMemoryAttachmentStore {
434    async fn put(
435        &self,
436        bytes: Vec<u8>,
437        meta: AttachmentCreateMeta,
438    ) -> Result<AttachmentRef, AttachmentStoreError> {
439        let meta = stored_meta(&bytes, meta);
440        let reference = meta.as_ref();
441        let now = now_epoch_ms();
442        let mut attachments = self.attachments.lock().expect("attachment store lock");
443        match attachments.entry(reference.id.clone()) {
444            std::collections::hash_map::Entry::Occupied(mut existing) => {
445                // Dedup hit: refresh the freshness signal so a GC sweep that
446                // snapshotted the roots before this put cannot reclaim the
447                // now-freshly-referenced blob.
448                existing.get_mut().stored_at_epoch_ms = now;
449            }
450            std::collections::hash_map::Entry::Vacant(slot) => {
451                slot.insert(InMemoryBlob {
452                    stored: StoredAttachment { bytes },
453                    stored_at_epoch_ms: now,
454                });
455            }
456        }
457        Ok(reference)
458    }
459
460    async fn get(&self, id: &AttachmentId) -> Result<StoredAttachment, AttachmentStoreError> {
461        self.attachments
462            .lock()
463            .expect("attachment store lock")
464            .get(id)
465            .map(|blob| blob.stored.clone())
466            .ok_or_else(|| AttachmentStoreError::NotFound(id.clone()))
467    }
468
469    async fn delete(&self, id: &AttachmentId) -> Result<(), AttachmentStoreError> {
470        self.attachments
471            .lock()
472            .expect("attachment store lock")
473            .remove(id);
474        Ok(())
475    }
476
477    async fn list(&self) -> Result<Vec<StoredBlobRef>, AttachmentStoreError> {
478        Ok(self
479            .attachments
480            .lock()
481            .expect("attachment store lock")
482            .iter()
483            .map(|(id, blob)| StoredBlobRef {
484                id: id.clone(),
485                last_modified_epoch_ms: Some(blob.stored_at_epoch_ms),
486            })
487            .collect())
488    }
489
490    async fn head(&self, id: &AttachmentId) -> Result<Option<StoredBlobRef>, AttachmentStoreError> {
491        Ok(self
492            .attachments
493            .lock()
494            .expect("attachment store lock")
495            .get(id)
496            .map(|blob| StoredBlobRef {
497                id: id.clone(),
498                last_modified_epoch_ms: Some(blob.stored_at_epoch_ms),
499            }))
500    }
501}
502
503pub fn content_id(bytes: &[u8]) -> AttachmentId {
504    AttachmentId::new(format!("{:x}", Sha256::digest(bytes)))
505}
506
507/// The concrete, session-bound facade over a flat [`AttachmentStore`] backend —
508/// the only attachment surface the runtime and its consumers ever see.
509///
510/// It binds a flat blob `backend`, an [`AttachmentManifest`] that tracks
511/// `(session_id, attachment_id)` refs, and a `session_id`. Every `put` records
512/// a write-ahead intent in the manifest *before* the bytes hit the backend, so
513/// a crash between `put` and the next durable commit surfaces as an uncommitted
514/// manifest row that GC reconciles. Every `get` first checks the manifest holds
515/// a ref for this session — the session-boundary guard that replaces physical
516/// per-session isolation: a turn in one session can never resolve another
517/// session's content-addressed blob by guessing its hash. `delete` drops the
518/// session's manifest ref and leaves the blob in place; the bytes die later via
519/// [`reclaim_unreferenced_attachments`] once no session references them.
520///
521/// Ephemeral runtimes (no durable reference store) wrap their backend with a
522/// [`NoopAttachmentManifest`] via [`SessionAttachmentStore::ephemeral`], so
523/// consumers still see exactly one type. A no-op manifest imposes no boundary
524/// guard (reads pass straight through) and records nothing.
525pub struct SessionAttachmentStore {
526    backend: Arc<dyn AttachmentStore>,
527    manifest: Arc<dyn AttachmentManifest>,
528    session_id: String,
529    owner: Mutex<Option<(crate::AttachmentOwnerKind, String)>>,
530    clock: Arc<dyn crate::Clock>,
531}
532
533pub(crate) struct AttachmentOwnerBinding {
534    store: Arc<SessionAttachmentStore>,
535    kind: crate::AttachmentOwnerKind,
536    owner_id: String,
537    previous: Option<(crate::AttachmentOwnerKind, String)>,
538}
539
540impl Drop for AttachmentOwnerBinding {
541    fn drop(&mut self) {
542        self.store
543            .restore_owner(self.kind, &self.owner_id, self.previous.take());
544    }
545}
546
547impl SessionAttachmentStore {
548    pub fn new(
549        backend: Arc<dyn AttachmentStore>,
550        manifest: Arc<dyn AttachmentManifest>,
551        session_id: impl Into<String>,
552    ) -> Self {
553        Self::new_with_clock(backend, manifest, session_id, Arc::new(crate::SystemClock))
554    }
555
556    pub(crate) fn new_with_clock(
557        backend: Arc<dyn AttachmentStore>,
558        manifest: Arc<dyn AttachmentManifest>,
559        session_id: impl Into<String>,
560        clock: Arc<dyn crate::Clock>,
561    ) -> Self {
562        Self {
563            backend,
564            manifest,
565            session_id: session_id.into(),
566            owner: Mutex::new(None),
567            clock,
568        }
569    }
570
571    /// Ephemeral facade: wrap `backend` with a no-op manifest and an empty
572    /// session id. No boundary guard, no reference tracking — used by ephemeral
573    /// runtimes and tests with no durable reference store.
574    pub fn ephemeral(backend: Arc<dyn AttachmentStore>) -> Self {
575        Self::new(backend, Arc::new(NoopAttachmentManifest), String::new())
576    }
577
578    /// Ephemeral facade over a fresh in-memory backend.
579    pub fn in_memory() -> Self {
580        Self::ephemeral(Arc::new(InMemoryAttachmentStore::new()))
581    }
582
583    pub fn backend(&self) -> &Arc<dyn AttachmentStore> {
584        &self.backend
585    }
586
587    pub fn manifest(&self) -> &Arc<dyn AttachmentManifest> {
588        &self.manifest
589    }
590
591    pub fn session_id(&self) -> &str {
592        &self.session_id
593    }
594
595    pub fn persistence(&self) -> AttachmentStorePersistence {
596        self.backend.persistence()
597    }
598
599    /// Bind puts for the lifetime of a durable turn execution.
600    pub(crate) fn bind_turn_scoped(
601        self: &Arc<Self>,
602        turn_id: impl Into<String>,
603    ) -> AttachmentOwnerBinding {
604        self.bind_owner_scoped(crate::AttachmentOwnerKind::Turn, turn_id.into())
605    }
606
607    /// Bind puts for the lifetime of a recovered ToolCall or Engine process.
608    pub(crate) fn bind_process_scoped(
609        self: &Arc<Self>,
610        process_id: impl Into<String>,
611    ) -> AttachmentOwnerBinding {
612        self.bind_owner_scoped(crate::AttachmentOwnerKind::Process, process_id.into())
613    }
614
615    fn bind_owner_scoped(
616        self: &Arc<Self>,
617        kind: crate::AttachmentOwnerKind,
618        owner_id: String,
619    ) -> AttachmentOwnerBinding {
620        let previous = self
621            .owner
622            .lock()
623            .expect("attachment owner binding lock")
624            .replace((kind, owner_id.clone()));
625        AttachmentOwnerBinding {
626            store: Arc::clone(self),
627            kind,
628            owner_id,
629            previous,
630        }
631    }
632
633    fn restore_owner(
634        &self,
635        kind: crate::AttachmentOwnerKind,
636        owner_id: &str,
637        previous: Option<(crate::AttachmentOwnerKind, String)>,
638    ) {
639        let mut owner = self.owner.lock().expect("attachment owner binding lock");
640        if owner
641            .as_ref()
642            .is_some_and(|(current_kind, id)| *current_kind == kind && id == owner_id)
643        {
644            *owner = previous;
645        }
646    }
647
648    pub async fn put(
649        &self,
650        bytes: Vec<u8>,
651        meta: AttachmentCreateMeta,
652    ) -> Result<AttachmentRef, AttachmentStoreError> {
653        let attachment_id = content_id(&bytes);
654        let owner = self
655            .owner
656            .lock()
657            .expect("attachment owner binding lock")
658            .clone();
659        let intent = AttachmentIntent {
660            attachment_id: attachment_id.clone(),
661            session_id: self.session_id.clone(),
662            canonical_uri: attachment_uri(&attachment_id),
663            intent_at_epoch_ms: self.clock.timestamp_ms(),
664            owner_kind: owner.as_ref().map(|(kind, _)| *kind),
665            owner_id: owner.map(|(_, id)| id),
666        };
667        // Record intent first. If this fails the bytes never land, matching the
668        // write-ahead guarantee.
669        self.manifest.record_intent(intent).map_err(|err| {
670            AttachmentStoreError::ManifestRecordFailed(format!(
671                "failed to record attachment intent for `{attachment_id}`: {err}"
672            ))
673        })?;
674        let reference = self.backend.put(bytes, meta).await?;
675        if reference.id != attachment_id {
676            return Err(AttachmentStoreError::Backend(format!(
677                "attachment store returned id `{}` after manifest intent for `{attachment_id}`",
678                reference.id
679            )));
680        }
681        Ok(reference)
682    }
683
684    pub async fn get(&self, id: &AttachmentId) -> Result<StoredAttachment, AttachmentStoreError> {
685        // Session-boundary guard: refuse to resolve a blob this session never
686        // referenced, even if the backend physically holds identical bytes for
687        // another session.
688        let holds_ref = self
689            .manifest
690            .holds_ref(&self.session_id, id)
691            .map_err(|err| {
692                AttachmentStoreError::Backend(format!(
693                    "failed to check attachment manifest for `{id}`: {err}"
694                ))
695            })?;
696        if !holds_ref {
697            return Err(AttachmentStoreError::NotFound(id.clone()));
698        }
699        self.backend.get(id).await
700    }
701
702    pub async fn delete(&self, id: &AttachmentId) -> Result<(), AttachmentStoreError> {
703        // Drop this session's manifest ref. Backend bytes stay put; they are
704        // reclaimed by GC once no session references them.
705        self.manifest.forget(&self.session_id, id).map_err(|err| {
706            AttachmentStoreError::ManifestRecordFailed(format!(
707                "failed to forget attachment ref for `{id}`: {err}"
708            ))
709        })?;
710        Ok(())
711    }
712}
713
714/// No-op [`AttachmentManifest`] for ephemeral facades: records nothing, imposes
715/// no boundary guard (`holds_ref` returns `true`), and exposes no refs. The
716/// backend is the sole source of truth for these runtimes.
717pub struct NoopAttachmentManifest;
718
719impl AttachmentManifest for NoopAttachmentManifest {
720    fn record_intent(&self, _intent: AttachmentIntent) -> Result<(), StoreError> {
721        Ok(())
722    }
723
724    fn commit_refs(
725        &self,
726        _session_id: &str,
727        _attachment_ids: &[AttachmentId],
728    ) -> Result<(), StoreError> {
729        Ok(())
730    }
731
732    fn list_uncommitted(
733        &self,
734        _older_than_epoch_ms: u64,
735    ) -> Result<Vec<crate::AttachmentManifestEntry>, StoreError> {
736        Ok(Vec::new())
737    }
738
739    fn forget(&self, _session_id: &str, _attachment_id: &AttachmentId) -> Result<(), StoreError> {
740        Ok(())
741    }
742
743    fn holds_ref(
744        &self,
745        _session_id: &str,
746        _attachment_id: &AttachmentId,
747    ) -> Result<bool, StoreError> {
748        Ok(true)
749    }
750
751    fn list_all_refs(&self) -> Result<Vec<AttachmentId>, StoreError> {
752        Ok(Vec::new())
753    }
754}
755
756fn attachment_uri(attachment_id: &AttachmentId) -> String {
757    format!("lash-attachment://sha256/{attachment_id}")
758}
759
760fn now_epoch_ms() -> u64 {
761    <crate::SystemClock as crate::Clock>::timestamp_ms(&crate::SystemClock)
762}
763
764/// Adapter that exposes the [`AttachmentManifest`] supertrait of an
765/// `Arc<dyn RuntimePersistence>` as an `Arc<dyn AttachmentManifest>`.
766/// Rust's trait-object upcasting does not yet allow direct coercion
767/// between the two; this thin forwarder is the bridge.
768pub(crate) struct PersistenceManifestAdapter(pub Arc<dyn crate::RuntimePersistence>);
769
770impl AttachmentManifest for PersistenceManifestAdapter {
771    fn record_intent(&self, intent: AttachmentIntent) -> Result<(), crate::StoreError> {
772        AttachmentManifest::record_intent(&*self.0, intent)
773    }
774
775    fn commit_refs(
776        &self,
777        session_id: &str,
778        attachment_ids: &[AttachmentId],
779    ) -> Result<(), crate::StoreError> {
780        AttachmentManifest::commit_refs(&*self.0, session_id, attachment_ids)
781    }
782
783    fn list_uncommitted(
784        &self,
785        older_than_epoch_ms: u64,
786    ) -> Result<Vec<crate::AttachmentManifestEntry>, crate::StoreError> {
787        AttachmentManifest::list_uncommitted(&*self.0, older_than_epoch_ms)
788    }
789
790    fn forget(
791        &self,
792        session_id: &str,
793        attachment_id: &AttachmentId,
794    ) -> Result<(), crate::StoreError> {
795        AttachmentManifest::forget(&*self.0, session_id, attachment_id)
796    }
797
798    fn holds_ref(
799        &self,
800        session_id: &str,
801        attachment_id: &AttachmentId,
802    ) -> Result<bool, crate::StoreError> {
803        AttachmentManifest::holds_ref(&*self.0, session_id, attachment_id)
804    }
805
806    fn list_all_refs(&self) -> Result<Vec<AttachmentId>, crate::StoreError> {
807        AttachmentManifest::list_all_refs(&*self.0)
808    }
809}
810
811fn stored_meta(bytes: &[u8], meta: AttachmentCreateMeta) -> AttachmentMeta {
812    AttachmentMeta::new(
813        content_id(bytes),
814        meta.media_type,
815        bytes.len() as u64,
816        meta.type_metadata,
817        meta.label,
818    )
819}
820
821pub async fn resolve_llm_request_attachments(
822    mut request: crate::llm::types::LlmRequest,
823    store: &SessionAttachmentStore,
824) -> Result<crate::llm::types::LlmRequest, AttachmentStoreError> {
825    for attachment in &request.attachments {
826        let crate::AttachmentSource::Stored { attachment_ref } = attachment else {
827            continue;
828        };
829        if request.resolved_stored.contains_key(&attachment_ref.id) {
830            continue;
831        }
832        let stored = store.get(&attachment_ref.id).await?;
833        request
834            .resolved_stored
835            .insert(attachment_ref.id.clone(), stored.bytes);
836    }
837    Ok(request)
838}
839
840#[cfg(test)]
841mod tests {
842    use super::*;
843    use lash_sansio::{AttachmentTypeMetadata, MediaType};
844
845    #[derive(Default)]
846    struct RecordingManifest {
847        entries: Mutex<HashMap<(String, AttachmentId), crate::AttachmentManifestEntry>>,
848    }
849
850    impl AttachmentManifest for RecordingManifest {
851        fn record_intent(&self, intent: AttachmentIntent) -> Result<(), crate::StoreError> {
852            let key = (intent.session_id.clone(), intent.attachment_id.clone());
853            self.entries
854                .lock()
855                .expect("lock entries")
856                .entry(key)
857                .or_insert(crate::AttachmentManifestEntry {
858                    attachment_id: intent.attachment_id,
859                    session_id: intent.session_id,
860                    canonical_uri: intent.canonical_uri,
861                    intent_at_epoch_ms: intent.intent_at_epoch_ms,
862                    committed_at_epoch_ms: None,
863                    owner_kind: intent.owner_kind,
864                    owner_id: intent.owner_id,
865                });
866            Ok(())
867        }
868
869        fn commit_refs(
870            &self,
871            session_id: &str,
872            attachment_ids: &[AttachmentId],
873        ) -> Result<(), crate::StoreError> {
874            let mut entries = self.entries.lock().expect("lock entries");
875            for attachment_id in attachment_ids {
876                if let Some(entry) =
877                    entries.get_mut(&(session_id.to_string(), attachment_id.clone()))
878                {
879                    entry.committed_at_epoch_ms.get_or_insert(1);
880                }
881            }
882            Ok(())
883        }
884
885        fn list_uncommitted(
886            &self,
887            older_than_epoch_ms: u64,
888        ) -> Result<Vec<crate::AttachmentManifestEntry>, crate::StoreError> {
889            Ok(self
890                .entries
891                .lock()
892                .expect("lock entries")
893                .values()
894                .filter(|entry| {
895                    entry.committed_at_epoch_ms.is_none()
896                        && entry.intent_at_epoch_ms <= older_than_epoch_ms
897                })
898                .cloned()
899                .collect())
900        }
901
902        fn forget(
903            &self,
904            session_id: &str,
905            attachment_id: &AttachmentId,
906        ) -> Result<(), crate::StoreError> {
907            self.entries
908                .lock()
909                .expect("lock entries")
910                .remove(&(session_id.to_string(), attachment_id.clone()));
911            Ok(())
912        }
913
914        fn holds_ref(
915            &self,
916            session_id: &str,
917            attachment_id: &AttachmentId,
918        ) -> Result<bool, crate::StoreError> {
919            Ok(self
920                .entries
921                .lock()
922                .expect("lock entries")
923                .contains_key(&(session_id.to_string(), attachment_id.clone())))
924        }
925
926        fn list_all_refs(&self) -> Result<Vec<AttachmentId>, crate::StoreError> {
927            Ok(self
928                .entries
929                .lock()
930                .expect("lock entries")
931                .values()
932                .map(|entry| entry.attachment_id.clone())
933                .collect())
934        }
935    }
936
937    /// Root set backed by a set of [`RecordingManifest`]s — the in-memory
938    /// analogue of a factory unioning its sessions' refs.
939    struct RecordingRootSet {
940        manifests: Vec<Arc<RecordingManifest>>,
941    }
942
943    #[async_trait::async_trait]
944    impl AttachmentRootSet for RecordingRootSet {
945        async fn live_attachment_refs(
946            &self,
947            intent_grace_cutoff_epoch_ms: u64,
948        ) -> Result<BTreeSet<AttachmentId>, crate::StoreError> {
949            let mut refs = BTreeSet::new();
950            for manifest in &self.manifests {
951                // This test root set contains only ownerless host puts, whose
952                // documented fallback remains age-only reconciliation.
953                for aged in manifest.list_uncommitted(intent_grace_cutoff_epoch_ms)? {
954                    manifest.forget(&aged.session_id, &aged.attachment_id)?;
955                }
956                refs.extend(manifest.list_all_refs()?);
957            }
958            Ok(refs)
959        }
960    }
961
962    fn meta() -> AttachmentCreateMeta {
963        AttachmentCreateMeta::new(
964            MediaType::parse("image/png").unwrap(),
965            Some(AttachmentTypeMetadata::image(Some(1), Some(1))),
966            Some("pixel".to_string()),
967        )
968    }
969
970    #[tokio::test]
971    async fn memory_store_dedupes_by_bytes() {
972        let store = InMemoryAttachmentStore::new();
973        let a = store.put(vec![1, 2, 3], meta()).await.expect("put a");
974        let b = store.put(vec![1, 2, 3], meta()).await.expect("put b");
975        assert_eq!(a.id, b.id);
976        assert_eq!(a.byte_len, 3);
977        assert_eq!(store.get(&a.id).await.expect("get").bytes, vec![1, 2, 3]);
978    }
979
980    #[tokio::test]
981    async fn memory_store_assigns_identity_and_byte_len_from_bytes() {
982        let store = InMemoryAttachmentStore::new();
983        let reference = store.put(vec![4, 5, 6, 7], meta()).await.expect("put");
984
985        assert_eq!(reference.id, content_id(&[4, 5, 6, 7]));
986        assert_eq!(reference.byte_len, 4);
987    }
988
989    #[tokio::test]
990    async fn memory_store_lists_stored_blobs() {
991        let store = InMemoryAttachmentStore::new();
992        let a = store.put(vec![1], meta()).await.expect("put a");
993        let b = store.put(vec![2], meta()).await.expect("put b");
994        let listed: BTreeSet<AttachmentId> = store
995            .list()
996            .await
997            .expect("list")
998            .into_iter()
999            .map(|blob| blob.id)
1000            .collect();
1001        assert!(listed.contains(&a.id));
1002        assert!(listed.contains(&b.id));
1003        assert_eq!(listed.len(), 2);
1004    }
1005
1006    #[tokio::test]
1007    async fn facade_get_is_gated_by_manifest_ownership() {
1008        let backend: Arc<dyn AttachmentStore> = Arc::new(InMemoryAttachmentStore::new());
1009        let manifest: Arc<dyn AttachmentManifest> = Arc::new(RecordingManifest::default());
1010        let session_a = SessionAttachmentStore::new(backend.clone(), manifest.clone(), "session-a");
1011        let session_b = SessionAttachmentStore::new(backend.clone(), manifest.clone(), "session-b");
1012
1013        let reference = session_a.put(vec![7, 7, 7], meta()).await.expect("put a");
1014        // Session A holds the ref and resolves the blob.
1015        assert_eq!(
1016            session_a.get(&reference.id).await.expect("a reads").bytes,
1017            vec![7, 7, 7]
1018        );
1019        // Session B shares the backend blob but never referenced it: NotFound.
1020        assert!(matches!(
1021            session_b.get(&reference.id).await,
1022            Err(AttachmentStoreError::NotFound(_))
1023        ));
1024    }
1025
1026    #[tokio::test]
1027    async fn facade_delete_drops_ref_but_keeps_backend_bytes() {
1028        let backend: Arc<dyn AttachmentStore> = Arc::new(InMemoryAttachmentStore::new());
1029        let manifest: Arc<dyn AttachmentManifest> = Arc::new(RecordingManifest::default());
1030        let session = SessionAttachmentStore::new(backend.clone(), manifest, "session-1");
1031
1032        let reference = session.put(vec![9, 9], meta()).await.expect("put");
1033        session.delete(&reference.id).await.expect("delete ref");
1034
1035        // Facade no longer resolves it (ref dropped)...
1036        assert!(matches!(
1037            session.get(&reference.id).await,
1038            Err(AttachmentStoreError::NotFound(_))
1039        ));
1040        // ...but the backend still physically holds the bytes (GC's job).
1041        assert_eq!(
1042            backend
1043                .get(&reference.id)
1044                .await
1045                .expect("bytes remain")
1046                .bytes,
1047            vec![9, 9]
1048        );
1049    }
1050
1051    #[tokio::test]
1052    async fn shared_bytes_survive_until_all_refs_released_then_gc_collects() {
1053        let backend: Arc<dyn AttachmentStore> = Arc::new(InMemoryAttachmentStore::new());
1054        let manifest_a = Arc::new(RecordingManifest::default());
1055        let manifest_b = Arc::new(RecordingManifest::default());
1056        let session_a = SessionAttachmentStore::new(
1057            backend.clone(),
1058            manifest_a.clone() as Arc<dyn AttachmentManifest>,
1059            "session-a",
1060        );
1061        let session_b = SessionAttachmentStore::new(
1062            backend.clone(),
1063            manifest_b.clone() as Arc<dyn AttachmentManifest>,
1064            "session-b",
1065        );
1066
1067        // Two sessions put identical bytes: ONE physical blob. Commit both refs
1068        // so they are stable committed roots (not grace-gated intents), letting
1069        // this test exercise ref-driven collection independently of intent aging.
1070        let ref_a = session_a.put(vec![5, 5, 5], meta()).await.expect("put a");
1071        let ref_b = session_b.put(vec![5, 5, 5], meta()).await.expect("put b");
1072        assert_eq!(ref_a.id, ref_b.id);
1073        manifest_a
1074            .commit_refs("session-a", std::slice::from_ref(&ref_a.id))
1075            .expect("commit a");
1076        manifest_b
1077            .commit_refs("session-b", std::slice::from_ref(&ref_b.id))
1078            .expect("commit b");
1079        assert_eq!(backend.list().await.expect("list").len(), 1);
1080
1081        let root_set = RecordingRootSet {
1082            manifests: vec![manifest_a.clone(), manifest_b.clone()],
1083        };
1084
1085        // Session A releases its ref. Blob is still referenced by B: spared.
1086        session_a.delete(&ref_a.id).await.expect("a releases");
1087        let report = reclaim_unreferenced_attachments(&root_set, &*backend, 0)
1088            .await
1089            .expect("sweep with b holding a ref");
1090        assert_eq!(report.reclaimed_count, 0, "b still references the blob");
1091        assert_eq!(
1092            backend.get(&ref_b.id).await.expect("blob alive").bytes,
1093            vec![5, 5, 5]
1094        );
1095
1096        // Session B releases too. Now unreferenced: GC collects it.
1097        session_b.delete(&ref_b.id).await.expect("b releases");
1098        let report = reclaim_unreferenced_attachments(&root_set, &*backend, 0)
1099            .await
1100            .expect("sweep with no refs");
1101        assert_eq!(report.reclaimed_count, 1);
1102        assert!(matches!(
1103            backend.get(&ref_b.id).await,
1104            Err(AttachmentStoreError::NotFound(_))
1105        ));
1106    }
1107
1108    #[tokio::test]
1109    async fn gc_spares_fresh_in_flight_intents_as_refs() {
1110        let backend: Arc<dyn AttachmentStore> = Arc::new(InMemoryAttachmentStore::new());
1111        let manifest = Arc::new(RecordingManifest::default());
1112        let session = SessionAttachmentStore::new(
1113            backend.clone(),
1114            manifest.clone() as Arc<dyn AttachmentManifest>,
1115            "session-1",
1116        );
1117
1118        // Fresh ownerless host intent: the legacy fallback retains it through
1119        // the grace window.
1120        let reference = session.put(vec![3, 1, 4], meta()).await.expect("put");
1121        let root_set = RecordingRootSet {
1122            manifests: vec![manifest.clone()],
1123        };
1124        const GRACE_MS: u64 = 60 * 60 * 1000;
1125        let report = reclaim_unreferenced_attachments(&root_set, &*backend, GRACE_MS)
1126            .await
1127            .expect("sweep");
1128        assert_eq!(report.reclaimed_count, 0, "a fresh intent is a live ref");
1129        assert_eq!(
1130            backend.get(&reference.id).await.expect("kept").bytes,
1131            vec![3, 1, 4]
1132        );
1133    }
1134
1135    // Ownerless host puts have no durable liveness proof, so their fallback is
1136    // age-only: an old intent is reconciled and a fresh one survives.
1137    #[tokio::test]
1138    async fn gc_collects_aged_uncommitted_intent_orphan() {
1139        let backend: Arc<dyn AttachmentStore> = Arc::new(InMemoryAttachmentStore::new());
1140        let manifest = Arc::new(RecordingManifest::default());
1141        let session = SessionAttachmentStore::new(
1142            backend.clone(),
1143            manifest.clone() as Arc<dyn AttachmentManifest>,
1144            "session-1",
1145        );
1146
1147        // With a zero grace window the ownerless intent is already eligible.
1148        let orphan = session
1149            .put(vec![9, 9, 9], meta())
1150            .await
1151            .expect("put orphan");
1152        let root_set = RecordingRootSet {
1153            manifests: vec![manifest.clone()],
1154        };
1155        let report = reclaim_unreferenced_attachments(&root_set, &*backend, 0)
1156            .await
1157            .expect("sweep");
1158        assert_eq!(
1159            report.reclaimed_count, 1,
1160            "an aged, never-committed intent is a collectable orphan"
1161        );
1162        assert!(matches!(
1163            backend.get(&orphan.id).await,
1164            Err(AttachmentStoreError::NotFound(_))
1165        ));
1166        // The intent row was reconciled away too, so it is no longer a root.
1167        assert!(manifest.list_all_refs().expect("refs").is_empty());
1168    }
1169
1170    // Fix C: the GC delete-time re-check. A blob looks unreferenced and stale in
1171    // the `list` snapshot, but a new intent + `put` of the same content id landed
1172    // after the snapshot, refreshing the blob. The sweep must re-stat the blob via
1173    // `head` before deleting and spare it. Modelled sequentially with a backend
1174    // whose `list` reports a stale mtime and whose `head` reports a fresh one.
1175    #[tokio::test]
1176    async fn gc_delete_recheck_spares_blob_refreshed_after_snapshot() {
1177        struct StaleSnapshotStore {
1178            id: AttachmentId,
1179            list_mtime: u64,
1180            head_mtime: u64,
1181            deleted: Mutex<bool>,
1182        }
1183
1184        #[async_trait::async_trait]
1185        impl AttachmentStore for StaleSnapshotStore {
1186            async fn put(
1187                &self,
1188                _bytes: Vec<u8>,
1189                _meta: AttachmentCreateMeta,
1190            ) -> Result<AttachmentRef, AttachmentStoreError> {
1191                unreachable!("test does not put through this store")
1192            }
1193            async fn get(
1194                &self,
1195                id: &AttachmentId,
1196            ) -> Result<StoredAttachment, AttachmentStoreError> {
1197                Err(AttachmentStoreError::NotFound(id.clone()))
1198            }
1199            async fn delete(&self, _id: &AttachmentId) -> Result<(), AttachmentStoreError> {
1200                *self.deleted.lock().expect("lock") = true;
1201                Ok(())
1202            }
1203            async fn list(&self) -> Result<Vec<StoredBlobRef>, AttachmentStoreError> {
1204                // Stale snapshot: the blob looks old and unreferenced.
1205                Ok(vec![StoredBlobRef {
1206                    id: self.id.clone(),
1207                    last_modified_epoch_ms: Some(self.list_mtime),
1208                }])
1209            }
1210            async fn head(
1211                &self,
1212                id: &AttachmentId,
1213            ) -> Result<Option<StoredBlobRef>, AttachmentStoreError> {
1214                // Fresh re-stat: the new intent's `put` refreshed the blob.
1215                Ok((id == &self.id).then(|| StoredBlobRef {
1216                    id: id.clone(),
1217                    last_modified_epoch_ms: Some(self.head_mtime),
1218                }))
1219            }
1220        }
1221
1222        let now = now_epoch_ms();
1223        const GRACE_MS: u64 = 60 * 60 * 1000;
1224        let backend = StaleSnapshotStore {
1225            id: AttachmentId::new("recheck"),
1226            // Stale mtime well past the grace window: the first check would delete.
1227            list_mtime: now.saturating_sub(GRACE_MS * 2),
1228            // Fresh mtime inside the window: the re-check must spare it.
1229            head_mtime: now,
1230            deleted: Mutex::new(false),
1231        };
1232        // Empty root set: the snapshot did not see the new intent.
1233        let root_set = RecordingRootSet { manifests: vec![] };
1234        let report = reclaim_unreferenced_attachments(&root_set, &backend, GRACE_MS)
1235            .await
1236            .expect("sweep");
1237        assert_eq!(
1238            report.reclaimed_count, 0,
1239            "the delete-time re-check must spare a blob refreshed after the snapshot"
1240        );
1241        assert!(
1242            !*backend.deleted.lock().expect("lock"),
1243            "the freshly-refreshed blob must not be deleted"
1244        );
1245    }
1246
1247    // Fix C, checks (b) and (d): the delete-window root re-check. A candidate blob
1248    // is stale in both the `list` snapshot AND the `head` re-stat (so the freshness
1249    // gate does not spare it), but a session records a fresh intent for the same
1250    // content id in the delete window. A backend whose `head` reports a stale mtime
1251    // and a root set scripted to answer the single-id probe drive the two branches:
1252    // (b) a ref present before delete spares the blob; (d) a ref that appears only
1253    // after delete is detected and alarmed via `deleted_while_referenced`.
1254    struct StaleHeadStore {
1255        id: AttachmentId,
1256        mtime: u64,
1257        deleted: Mutex<bool>,
1258    }
1259
1260    #[async_trait::async_trait]
1261    impl AttachmentStore for StaleHeadStore {
1262        async fn put(
1263            &self,
1264            _bytes: Vec<u8>,
1265            _meta: AttachmentCreateMeta,
1266        ) -> Result<AttachmentRef, AttachmentStoreError> {
1267            unreachable!("test does not put through this store")
1268        }
1269        async fn get(&self, id: &AttachmentId) -> Result<StoredAttachment, AttachmentStoreError> {
1270            Err(AttachmentStoreError::NotFound(id.clone()))
1271        }
1272        async fn delete(&self, _id: &AttachmentId) -> Result<(), AttachmentStoreError> {
1273            *self.deleted.lock().expect("lock") = true;
1274            Ok(())
1275        }
1276        async fn list(&self) -> Result<Vec<StoredBlobRef>, AttachmentStoreError> {
1277            Ok(vec![StoredBlobRef {
1278                id: self.id.clone(),
1279                last_modified_epoch_ms: Some(self.mtime),
1280            }])
1281        }
1282        async fn head(
1283            &self,
1284            id: &AttachmentId,
1285        ) -> Result<Option<StoredBlobRef>, AttachmentStoreError> {
1286            // Stale re-stat: the freshness gate does not spare the blob, so the
1287            // targeted root re-check is what must decide its fate.
1288            Ok((id == &self.id).then(|| StoredBlobRef {
1289                id: id.clone(),
1290                last_modified_epoch_ms: Some(self.mtime),
1291            }))
1292        }
1293    }
1294
1295    /// Root set whose single-id probe returns scripted answers in order — models
1296    /// a ref appearing at a chosen point in the delete window. `live_attachment_refs`
1297    /// is empty (the snapshot never saw the late ref).
1298    struct ScriptedRootSet {
1299        answers: Mutex<std::collections::VecDeque<bool>>,
1300    }
1301
1302    #[async_trait::async_trait]
1303    impl AttachmentRootSet for ScriptedRootSet {
1304        async fn live_attachment_refs(
1305            &self,
1306            _intent_grace_cutoff_epoch_ms: u64,
1307        ) -> Result<BTreeSet<AttachmentId>, crate::StoreError> {
1308            Ok(BTreeSet::new())
1309        }
1310
1311        async fn has_live_attachment_ref(
1312            &self,
1313            _id: &AttachmentId,
1314            _intent_grace_cutoff_epoch_ms: u64,
1315        ) -> Result<bool, crate::StoreError> {
1316            Ok(self
1317                .answers
1318                .lock()
1319                .expect("lock answers")
1320                .pop_front()
1321                .unwrap_or(false))
1322        }
1323    }
1324
1325    #[tokio::test]
1326    async fn gc_pre_delete_root_recheck_spares_reappeared_ref() {
1327        let now = now_epoch_ms();
1328        const GRACE_MS: u64 = 60 * 60 * 1000;
1329        let backend = StaleHeadStore {
1330            id: AttachmentId::new("reappeared"),
1331            // Well past the grace window: neither the snapshot nor the head re-stat
1332            // spares it, so the single-id root re-check is the only guard left.
1333            mtime: now.saturating_sub(GRACE_MS * 2),
1334            deleted: Mutex::new(false),
1335        };
1336        // (b) sees a live ref: the probe answers true before the delete.
1337        let root_set = ScriptedRootSet {
1338            answers: Mutex::new([true].into_iter().collect()),
1339        };
1340        let report = reclaim_unreferenced_attachments(&root_set, &backend, GRACE_MS)
1341            .await
1342            .expect("sweep");
1343        assert_eq!(
1344            report.reclaimed_count, 0,
1345            "pre-delete root re-check must spare the blob"
1346        );
1347        assert!(report.deleted_while_referenced.is_empty());
1348        assert!(
1349            !*backend.deleted.lock().expect("lock"),
1350            "a blob re-referenced before delete must not be deleted"
1351        );
1352    }
1353
1354    #[tokio::test]
1355    async fn gc_post_delete_root_recheck_alarms_on_window_ref() {
1356        let now = now_epoch_ms();
1357        const GRACE_MS: u64 = 60 * 60 * 1000;
1358        let id = AttachmentId::new("window-ref");
1359        let backend = StaleHeadStore {
1360            id: id.clone(),
1361            mtime: now.saturating_sub(GRACE_MS * 2),
1362            deleted: Mutex::new(false),
1363        };
1364        // (b) sees no ref (delete proceeds); (d) sees a ref that appeared in the
1365        // (b)->(c) window: detected and alarmed.
1366        let root_set = ScriptedRootSet {
1367            answers: Mutex::new([false, true].into_iter().collect()),
1368        };
1369        let report = reclaim_unreferenced_attachments(&root_set, &backend, GRACE_MS)
1370            .await
1371            .expect("sweep");
1372        assert_eq!(report.reclaimed_count, 1, "the blob is deleted");
1373        assert!(*backend.deleted.lock().expect("lock"), "delete happened");
1374        assert_eq!(
1375            report.deleted_while_referenced,
1376            vec![id],
1377            "a ref appearing in the delete window is recorded for the operator alarm"
1378        );
1379    }
1380
1381    #[tokio::test]
1382    async fn session_facade_records_bound_owner_on_put() {
1383        let manifest = Arc::new(RecordingManifest::default());
1384        let store = Arc::new(SessionAttachmentStore::new(
1385            Arc::new(InMemoryAttachmentStore::new()),
1386            manifest.clone(),
1387            "session-1",
1388        ));
1389        let binding = store.bind_turn_scoped("turn-1");
1390
1391        let reference = store.put(vec![8, 9, 10], meta()).await.expect("put");
1392        {
1393            let entries = manifest.entries.lock().expect("lock entries");
1394            let entry = entries
1395                .get(&("session-1".to_string(), reference.id))
1396                .expect("manifest entry");
1397            assert_eq!(entry.owner_kind, Some(crate::AttachmentOwnerKind::Turn));
1398            assert_eq!(entry.owner_id.as_deref(), Some("turn-1"));
1399        }
1400
1401        drop(binding);
1402        let host_reference = store.put(vec![11, 12], meta()).await.expect("host put");
1403        let entries = manifest.entries.lock().expect("lock entries");
1404        let host_entry = entries
1405            .get(&("session-1".to_string(), host_reference.id))
1406            .expect("host manifest entry");
1407        assert_eq!(host_entry.owner_kind, None);
1408        assert_eq!(host_entry.owner_id, None);
1409    }
1410
1411    #[tokio::test]
1412    async fn nested_owner_binding_restores_the_previous_owner() {
1413        let manifest = Arc::new(RecordingManifest::default());
1414        let store = Arc::new(SessionAttachmentStore::new(
1415            Arc::new(InMemoryAttachmentStore::new()),
1416            manifest.clone(),
1417            "session-1",
1418        ));
1419        let process_binding = store.bind_process_scoped("process-1");
1420        let turn_binding = store.bind_turn_scoped("turn-1");
1421
1422        let turn_ref = store.put(vec![1], meta()).await.expect("turn put");
1423        drop(turn_binding);
1424        let process_ref = store.put(vec![2], meta()).await.expect("process put");
1425        drop(process_binding);
1426        let host_ref = store.put(vec![3], meta()).await.expect("host put");
1427
1428        let entries = manifest.entries.lock().expect("lock entries");
1429        let turn = entries
1430            .get(&("session-1".to_string(), turn_ref.id))
1431            .expect("turn entry");
1432        assert_eq!(turn.owner_kind, Some(crate::AttachmentOwnerKind::Turn));
1433        assert_eq!(turn.owner_id.as_deref(), Some("turn-1"));
1434        let process = entries
1435            .get(&("session-1".to_string(), process_ref.id))
1436            .expect("process entry");
1437        assert_eq!(
1438            process.owner_kind,
1439            Some(crate::AttachmentOwnerKind::Process)
1440        );
1441        assert_eq!(process.owner_id.as_deref(), Some("process-1"));
1442        let host = entries
1443            .get(&("session-1".to_string(), host_ref.id))
1444            .expect("host entry");
1445        assert_eq!(host.owner_kind, None);
1446        assert_eq!(host.owner_id, None);
1447    }
1448
1449    #[tokio::test]
1450    async fn ephemeral_facade_passes_reads_through_without_a_guard() {
1451        let store = SessionAttachmentStore::in_memory();
1452        let reference = store.put(vec![1, 2, 3], meta()).await.expect("put");
1453        assert_eq!(
1454            store.get(&reference.id).await.expect("get").bytes,
1455            vec![1, 2, 3]
1456        );
1457    }
1458
1459    #[test]
1460    fn persistence_manifest_adapter_forwards_holds_ref() {
1461        let runtime: Arc<dyn crate::RuntimePersistence> =
1462            Arc::new(crate::InMemorySessionStore::new());
1463        let adapter = PersistenceManifestAdapter(runtime);
1464        let attachment_id = AttachmentId::new("adapter-forwarding");
1465        let intent = AttachmentIntent {
1466            attachment_id: attachment_id.clone(),
1467            session_id: "adapter-session".to_string(),
1468            canonical_uri: attachment_uri(&attachment_id),
1469            intent_at_epoch_ms: 10,
1470            owner_kind: None,
1471            owner_id: None,
1472        };
1473        adapter.record_intent(intent).expect("record intent");
1474        assert!(
1475            adapter
1476                .holds_ref("adapter-session", &attachment_id)
1477                .expect("holds ref")
1478        );
1479        assert!(
1480            !adapter
1481                .holds_ref("other-session", &attachment_id)
1482                .expect("no ref for other session")
1483        );
1484        assert_eq!(
1485            adapter.list_all_refs().expect("list all refs"),
1486            vec![attachment_id]
1487        );
1488    }
1489}