use std::sync::Arc;
use crate::blob::BlobStore;
use crate::case::CaseStore;
use crate::core::{CaseStatus, StoreError, Timestamp};
use crate::export::CASE_PAGE;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct RetentionReport {
pub scanned: usize,
pub erased: usize,
pub blobs_expired: usize,
pub failures: Vec<String>,
pub not_erasable: Vec<String>,
pub held: Vec<String>,
}
impl RetentionReport {
#[must_use]
pub fn is_complete(&self) -> bool {
self.failures.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct RetentionPlan {
pub scanned: usize,
pub due: Vec<crate::core::CaseId>,
pub held: Vec<crate::core::CaseId>,
}
pub async fn plan(
cases: &dyn CaseStore,
older_than: Timestamp,
) -> Result<RetentionPlan, StoreError> {
let mut plan = RetentionPlan {
scanned: 0,
due: Vec::new(),
held: Vec::new(),
};
let mut after = None;
loop {
let page = cases.cases(after, CASE_PAGE).await?;
if page.is_empty() {
break;
}
let full = page.len() >= CASE_PAGE;
after = page.last().map(|c| c.id);
for case in page {
plan.scanned += 1;
if case.status == CaseStatus::Closed && case.opened_at < older_than {
if cases.hold(case.id).await?.is_some() {
plan.held.push(case.id);
} else {
plan.due.push(case.id);
}
}
}
if !full {
break;
}
}
Ok(plan)
}
#[derive(Debug)]
pub struct Stores<'a> {
pub cases: &'a Arc<dyn CaseStore>,
pub blobs: Option<&'a Arc<dyn BlobStore>>,
#[cfg(feature = "keyring")]
pub keys: Option<&'a Arc<dyn crate::keyring::KeyRing>>,
pub tenant: &'a crate::core::TenantId,
}
pub async fn retain(
stores: &Stores<'_>,
older_than: Timestamp,
at: Timestamp,
reason: &str,
) -> Result<RetentionReport, StoreError> {
let mut report = RetentionReport {
scanned: 0,
erased: 0,
blobs_expired: 0,
failures: Vec::new(),
not_erasable: Vec::new(),
held: Vec::new(),
};
if stores.blobs.is_none() {
report.not_erasable.push(
"no blob store is wired: linked blobs were not tombstoned. On a sealed plane \
their bytes are unreadable through the destroyed key; on an unsealed one they \
are untouched"
.to_owned(),
);
}
#[cfg(feature = "keyring")]
if stores.keys.is_none() {
report.not_erasable.push(
"no key ring is wired: blob tombstones cover the live store only, and journal \
payloads — run input, prompts, tool arguments, effect outputs — stay verbatim \
and permanent. Wire `RuntimeBuilder::keyring(..)`, or declare \
`max_sensitivity_journaled` and keep the data out of records"
.to_owned(),
);
}
#[cfg(not(feature = "keyring"))]
report.not_erasable.push(
"this build has no `keyring` feature: blob tombstones cover the live store only, and \
journal payloads stay verbatim and permanent"
.to_owned(),
);
let selected = plan(stores.cases.as_ref(), older_than).await?;
report.scanned = selected.scanned;
for case in &selected.held {
let reason = stores.cases.hold(*case).await.ok().flatten().map_or_else(
|| "hold released while this pass ran".to_owned(),
|h| format!("placed at {} — {}", h.placed_at, h.reason),
);
report.held.push(format!(
"case {case}: preserved under a legal hold, {reason}"
));
}
for case in selected.due {
let erased = crate::blob::erase_case(
stores.blobs.map(std::convert::AsRef::as_ref),
stores.cases.as_ref(),
#[cfg(feature = "keyring")]
stores.keys.map(std::convert::AsRef::as_ref),
stores.tenant,
case,
at,
reason,
)
.await;
match erased {
Ok(n) => {
report.erased += 1;
report.blobs_expired += n;
}
Err(crate::blob::EraseError::UnderLegalHold {
case,
placed_at,
reason,
}) => report.held.push(format!(
"case {case}: preserved under a legal hold placed at {placed_at} — {reason}"
)),
Err(e) => report
.failures
.push(format!("case {case} could not be erased: {e}")),
}
}
Ok(finish(report))
}
fn finish(mut report: RetentionReport) -> RetentionReport {
report.not_erasable.push(
"journal records are append-only: the chain, the routing fields and the fact each run \
happened remain — by design, so an auditor with no keys still verifies a run whose \
payloads are gone"
.to_owned(),
);
report.not_erasable.push(
"a run that belongs to no case is not reached by a case walk; erase one with \
`blob::erase_run`"
.to_owned(),
);
report
.not_erasable
.extend(OUTSIDE_THE_CASE.iter().map(|line| (*line).to_owned()));
report
}
const OUTSIDE_THE_CASE: [&str; 4] = [
"governed memory is erased by item and by subject, never by case — including memory a \
declaration keyed to `$case` or `$correlation/<namespace>`, whose subject is the case id \
or a business key; erase those subjects with `EncryptedMemoryStore::erase_subject`",
"an inbound event is its own erasure unit: the event buffer's copy, and every backup of \
it, is erased by `SealedEvents::erase_event` for its `(source, id)`, not by the case the \
message was delivered into",
"media fetched under a named external retention policy belongs to that policy's unit, not \
the case, so this pass neither tombstones nor unseals it — its lifecycle controller does",
"semantic-index vectors are derived from memory content and live in the retriever's index; \
nothing here removes them — delete them where the index is kept",
];