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