use crate::agentic::compress::redact_secrets;
use content_addressable::{canonical, ContentAddressable, ContentError, ContentId};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
pub const SPILL_SCHEMA_V1: &str = "newt.spill/v1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SpillScope {
Session([u8; 16]),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SpillProvenance {
ToolOutput { tool_name: Option<String> },
CompactionSpan,
ReportReview,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpillRecordV1 {
pub schema: String,
pub scope: SpillScope,
pub provenance: SpillProvenance,
pub redacted_text: String,
}
impl SpillRecordV1 {
pub fn new(scope: SpillScope, provenance: SpillProvenance, redacted_text: String) -> Self {
Self {
schema: SPILL_SCHEMA_V1.to_string(),
scope,
provenance,
redacted_text,
}
}
}
impl ContentAddressable for SpillRecordV1 {
fn canonical_form(&self) -> Result<Vec<u8>, ContentError> {
canonical::to_canonical_dagcbor(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SpillCidError {
SurroundingWhitespace,
NonCanonicalPresentation,
Profile(String),
}
impl std::fmt::Display for SpillCidError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::SurroundingWhitespace => write!(f, "handle has surrounding whitespace"),
Self::NonCanonicalPresentation => {
write!(
f,
"handle is not the canonical base32-lower CID presentation"
)
}
Self::Profile(e) => write!(f, "handle is not a valid content-address: {e}"),
}
}
}
impl std::error::Error for SpillCidError {}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct SpillCid(ContentId);
impl SpillCid {
pub fn of(record: &SpillRecordV1) -> Result<Self, ContentError> {
Ok(Self(record.content_id()?))
}
pub fn parse(s: &str) -> Result<Self, SpillCidError> {
if s != s.trim() {
return Err(SpillCidError::SurroundingWhitespace);
}
let cid = ContentId::from_str(s).map_err(|e| SpillCidError::Profile(e.to_string()))?;
if cid.to_string() != s {
return Err(SpillCidError::NonCanonicalPresentation);
}
Ok(Self(cid))
}
pub fn to_handle(self) -> String {
self.0.to_string()
}
pub fn as_content_id(&self) -> &ContentId {
&self.0
}
}
impl std::fmt::Display for SpillCid {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Clone, Debug)]
pub struct StagedSpill {
cid: SpillCid,
record: SpillRecordV1,
canonical_bytes: Arc<[u8]>,
redacted_chars: usize,
}
impl StagedSpill {
pub fn from_record(record: SpillRecordV1) -> Result<Self, ContentError> {
let bytes = record.canonical_form()?;
let cid = SpillCid(ContentId::from_canonical_bytes(&bytes));
let redacted_chars = record.redacted_text.chars().count();
Ok(Self {
cid,
record,
canonical_bytes: Arc::from(bytes.into_boxed_slice()),
redacted_chars,
})
}
pub fn cid(&self) -> &SpillCid {
&self.cid
}
pub fn handle(&self) -> String {
self.cid.to_handle()
}
pub fn redacted_chars(&self) -> usize {
self.redacted_chars
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CommittedSpill {
cid: SpillCid,
}
impl CommittedSpill {
pub fn cid(&self) -> &SpillCid {
&self.cid
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SpillError {
PoisonedStore,
IntegrityViolation { cid: String },
Encoding(String),
}
pub trait SpillStore: Send + Sync {
fn stage(
&self,
provenance: SpillProvenance,
redacted_text: String,
) -> Result<StagedSpill, SpillError>;
fn commit_batch(&self, staged: &[StagedSpill]) -> Result<Vec<CommittedSpill>, SpillError>;
fn fetch(&self, cid: &SpillCid) -> Option<SpillRecordV1>;
fn store(&self, record: SpillRecordV1) -> Result<SpillCid, SpillError> {
let staged =
StagedSpill::from_record(record).map_err(|e| SpillError::Encoding(e.to_string()))?;
let cid = *staged.cid();
self.commit_batch(std::slice::from_ref(&staged))?;
Ok(cid)
}
fn unique_objects(&self) -> u64;
fn logical_spill_refs(&self) -> u64;
fn unique_offloaded_chars(&self) -> u64;
fn logical_offloaded_chars(&self) -> u64;
}
pub struct SessionSpillStore {
scope: SpillScope,
map: Mutex<HashMap<String, SpillRecordV1>>,
unique_objects: AtomicU64,
logical_refs: AtomicU64,
unique_offloaded_chars: AtomicU64,
logical_offloaded_chars: AtomicU64,
}
impl SessionSpillStore {
pub fn new(session_nonce: [u8; 16]) -> Self {
Self {
scope: SpillScope::Session(session_nonce),
map: Mutex::new(HashMap::new()),
unique_objects: AtomicU64::new(0),
logical_refs: AtomicU64::new(0),
unique_offloaded_chars: AtomicU64::new(0),
logical_offloaded_chars: AtomicU64::new(0),
}
}
}
impl SpillStore for SessionSpillStore {
fn stage(
&self,
provenance: SpillProvenance,
redacted_text: String,
) -> Result<StagedSpill, SpillError> {
let record = SpillRecordV1::new(self.scope.clone(), provenance, redacted_text);
StagedSpill::from_record(record).map_err(|e| SpillError::Encoding(e.to_string()))
}
fn commit_batch(&self, staged: &[StagedSpill]) -> Result<Vec<CommittedSpill>, SpillError> {
let mut map = self.map.lock().map_err(|_| SpillError::PoisonedStore)?;
for s in staged {
if let Some(existing) = map.get(&s.handle()) {
let existing_bytes = existing
.canonical_form()
.map_err(|e| SpillError::Encoding(e.to_string()))?;
if existing_bytes.as_slice() != &s.canonical_bytes[..] {
return Err(SpillError::IntegrityViolation { cid: s.handle() });
}
}
}
let mut out = Vec::with_capacity(staged.len());
for s in staged {
if let std::collections::hash_map::Entry::Vacant(e) = map.entry(s.handle()) {
self.unique_objects.fetch_add(1, Ordering::Relaxed);
self.unique_offloaded_chars
.fetch_add(s.redacted_chars as u64, Ordering::Relaxed);
e.insert(s.record.clone());
}
self.logical_refs.fetch_add(1, Ordering::Relaxed);
self.logical_offloaded_chars
.fetch_add(s.redacted_chars as u64, Ordering::Relaxed);
out.push(CommittedSpill { cid: s.cid });
}
Ok(out)
}
fn fetch(&self, cid: &SpillCid) -> Option<SpillRecordV1> {
self.map.lock().ok()?.get(&cid.to_handle()).cloned()
}
fn unique_objects(&self) -> u64 {
self.unique_objects.load(Ordering::Relaxed)
}
fn logical_spill_refs(&self) -> u64 {
self.logical_refs.load(Ordering::Relaxed)
}
fn unique_offloaded_chars(&self) -> u64 {
self.unique_offloaded_chars.load(Ordering::Relaxed)
}
fn logical_offloaded_chars(&self) -> u64 {
self.logical_offloaded_chars.load(Ordering::Relaxed)
}
}
pub(crate) const TOOL_RESULT_SPILL_CAP: usize = 16_000;
const HEAD_CHARS: usize = 800;
const TAIL_CHARS: usize = 800;
pub(crate) fn tool_output_retrieval_hint(handle: &str) -> String {
let arguments = serde_json::json!({"address": format!("spill:{handle}")});
format!(
"Call the memory_fetch tool with JSON arguments {arguments} to read the full \
(secret-redacted) payload. To search, add \"grep\":\"<pattern>\" to the arguments. \
The spill address is local to this session."
)
}
fn head_tail_excerpt(redacted: &str, handle: &str) -> String {
let chars: Vec<char> = redacted.chars().collect();
let total = chars.len();
let head: String = chars.iter().take(HEAD_CHARS).collect();
let tail: String = chars
.iter()
.skip(total.saturating_sub(TAIL_CHARS))
.collect();
let retrieval = tool_output_retrieval_hint(handle);
format!("{head}\n\n[… tool output truncated: {total} chars offloaded. {retrieval} …]\n\n{tail}")
}
pub(crate) fn maybe_offload(
result: String,
tool_offload: bool,
tool_name: Option<String>,
spill: Option<&dyn SpillStore>,
) -> String {
let Some(store) = spill else {
return result;
};
if !tool_offload || result.chars().count() <= TOOL_RESULT_SPILL_CAP {
return result;
}
let redacted = redact_secrets(&result);
match store.stage(SpillProvenance::ToolOutput { tool_name }, redacted.clone()) {
Ok(staged) => {
let handle = staged.handle();
match store.commit_batch(std::slice::from_ref(&staged)) {
Ok(_) => head_tail_excerpt(&redacted, &handle),
Err(_) => redacted,
}
}
Err(_) => redacted,
}
}
pub(crate) fn store_redacted_full(
result: &str,
tool_name: Option<String>,
spill: &dyn SpillStore,
) -> (Option<String>, String) {
let redacted = redact_secrets(result);
let handle = match spill.stage(SpillProvenance::ToolOutput { tool_name }, redacted.clone()) {
Ok(staged) => {
let handle = staged.handle();
spill
.commit_batch(std::slice::from_ref(&staged))
.ok()
.map(|_| handle)
}
Err(_) => None,
};
(handle, redacted)
}
#[cfg(test)]
impl SessionSpillStore {
fn corrupt_raw_for_test(&self, handle: String, record: SpillRecordV1) {
self.map.lock().unwrap().insert(handle, record);
}
}
#[cfg(test)]
mod tests {
use super::*;
const NONCE_A: [u8; 16] = [7u8; 16];
const NONCE_B: [u8; 16] = [9u8; 16];
fn tool_record(nonce: [u8; 16], text: &str) -> SpillRecordV1 {
SpillRecordV1::new(
SpillScope::Session(nonce),
SpillProvenance::ToolOutput {
tool_name: Some("read_file".into()),
},
text.into(),
)
}
#[test]
fn same_content_and_nonce_converge_on_one_cid() {
let store = SessionSpillStore::new(NONCE_A);
let a = StagedSpill::from_record(tool_record(NONCE_A, "same payload")).unwrap();
let b = StagedSpill::from_record(tool_record(NONCE_A, "same payload")).unwrap();
assert_eq!(a.cid(), b.cid(), "identical content ⇒ identical CID");
store.commit_batch(&[a, b]).unwrap();
assert_eq!(store.unique_objects(), 1, "dedup: one physical object");
assert_eq!(store.logical_spill_refs(), 2, "two logical references");
}
#[test]
fn different_session_nonce_yields_different_cid() {
let a = SpillCid::of(&tool_record(NONCE_A, "secret token XYZ")).unwrap();
let b = SpillCid::of(&tool_record(NONCE_B, "secret token XYZ")).unwrap();
assert_ne!(
a, b,
"different session nonce ⇒ different CID (no equality leak)"
);
}
#[test]
fn different_content_yields_different_cid() {
let a = SpillCid::of(&tool_record(NONCE_A, "payload X")).unwrap();
let b = SpillCid::of(&tool_record(NONCE_A, "payload Y")).unwrap();
assert_ne!(a, b);
}
#[test]
fn same_text_different_provenance_yields_different_cid() {
let tool = SpillRecordV1::new(
SpillScope::Session(NONCE_A),
SpillProvenance::ToolOutput { tool_name: None },
"identical body".into(),
);
let span = SpillRecordV1::new(
SpillScope::Session(NONCE_A),
SpillProvenance::CompactionSpan,
"identical body".into(),
);
assert_ne!(SpillCid::of(&tool).unwrap(), SpillCid::of(&span).unwrap());
}
#[test]
fn a_committed_cid_resolves_to_its_own_record() {
let store = SessionSpillStore::new(NONCE_A);
let rec = tool_record(NONCE_A, "resolve me");
let staged = StagedSpill::from_record(rec.clone()).unwrap();
let cid = *staged.cid();
store.commit_batch(&[staged]).unwrap();
assert_eq!(
store.fetch(&cid).as_ref(),
Some(&rec),
"handle → its OWN record"
);
}
#[test]
fn staging_is_pure_a_staged_cid_is_not_fetchable_until_committed() {
let store = SessionSpillStore::new(NONCE_A);
let staged = StagedSpill::from_record(tool_record(NONCE_A, "not yet live")).unwrap();
let cid = *staged.cid();
assert_eq!(store.fetch(&cid), None, "staging touches no store");
assert_eq!(store.unique_objects(), 0);
store.commit_batch(&[staged]).unwrap();
assert!(store.fetch(&cid).is_some(), "committed ⇒ resolvable");
}
#[test]
fn duplicate_commit_is_idempotent_success() {
let store = SessionSpillStore::new(NONCE_A);
let staged = StagedSpill::from_record(tool_record(NONCE_A, "dup")).unwrap();
store.commit_batch(std::slice::from_ref(&staged)).unwrap();
store
.commit_batch(std::slice::from_ref(&staged))
.expect("a second identical commit is an idempotent success");
assert_eq!(store.unique_objects(), 1, "still one physical object");
assert_eq!(store.logical_spill_refs(), 2);
}
#[test]
fn arbitrary_strings_do_not_become_a_spill_cid() {
assert!(
SpillCid::parse("not-a-cid").is_err(),
"malformed → rejected"
);
assert!(SpillCid::parse("").is_err());
let cid = SpillCid::of(&tool_record(NONCE_A, "round trip")).unwrap();
assert_eq!(SpillCid::parse(&cid.to_handle()).unwrap(), cid);
}
#[test]
fn canonical_input_policy_rejects_noncanonical_and_padded_handles() {
let cid = SpillCid::of(&tool_record(NONCE_A, "canonical policy")).unwrap();
let h = cid.to_handle();
assert_eq!(SpillCid::parse(&h).unwrap(), cid);
assert_eq!(
SpillCid::parse(&format!(" {h}")),
Err(SpillCidError::SurroundingWhitespace)
);
assert_eq!(
SpillCid::parse(&format!("{h}\n")),
Err(SpillCidError::SurroundingWhitespace)
);
assert_eq!(
SpillCid::parse(&h.to_uppercase()),
Err(SpillCidError::NonCanonicalPresentation)
);
assert!(matches!(
SpillCid::parse(&"a".repeat(64)),
Err(SpillCidError::Profile(_))
));
assert!(matches!(
SpillCid::parse("not-a-cid"),
Err(SpillCidError::Profile(_))
));
}
#[test]
fn store_convenience_stages_and_commits_with_the_session_scope() {
let store = SessionSpillStore::new(NONCE_A);
let staged = store
.stage(SpillProvenance::CompactionSpan, "scoped".into())
.unwrap();
let cid = *staged.cid();
let expect = SpillCid::of(&SpillRecordV1::new(
SpillScope::Session(NONCE_A),
SpillProvenance::CompactionSpan,
"scoped".into(),
))
.unwrap();
assert_eq!(cid, expect);
store.commit_batch(&[staged]).unwrap();
assert!(store.fetch(&cid).is_some());
}
#[test]
fn integrity_violation_on_divergent_bytes_under_the_same_cid() {
let store = SessionSpillStore::new(NONCE_A);
let genuine = tool_record(NONCE_A, "genuine payload");
let staged = StagedSpill::from_record(genuine.clone()).unwrap();
let handle = staged.handle();
store.corrupt_raw_for_test(handle.clone(), tool_record(NONCE_A, "IMPOSTER"));
let err = store.commit_batch(&[staged]).unwrap_err();
assert_eq!(err, SpillError::IntegrityViolation { cid: handle });
}
#[test]
fn a_batch_with_one_integrity_violation_installs_nothing() {
let store = SessionSpillStore::new(NONCE_A);
let clean = StagedSpill::from_record(tool_record(NONCE_A, "clean")).unwrap();
let clean_cid = *clean.cid();
let poisoned = StagedSpill::from_record(tool_record(NONCE_A, "poisoned")).unwrap();
store.corrupt_raw_for_test(poisoned.handle(), tool_record(NONCE_A, "IMPOSTER"));
assert!(store.commit_batch(&[clean, poisoned]).is_err());
assert_eq!(
store.fetch(&clean_cid),
None,
"all-or-none: clean not installed"
);
assert_eq!(store.unique_objects(), 0);
}
#[test]
fn poisoned_store_fails_closed() {
let store = SessionSpillStore::new(NONCE_A);
let staged = StagedSpill::from_record(tool_record(NONCE_A, "x")).unwrap();
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _g = store.map.lock().unwrap();
panic!("intentional poison");
}));
assert_eq!(
store.commit_batch(&[staged]),
Err(SpillError::PoisonedStore),
"a poisoned store fails closed"
);
}
#[test]
fn metrics_distinguish_unique_from_logical_chars() {
let store = SessionSpillStore::new(NONCE_A);
let text = "twelve chars"; let a = StagedSpill::from_record(tool_record(NONCE_A, text)).unwrap();
let b = StagedSpill::from_record(tool_record(NONCE_A, text)).unwrap();
store.commit_batch(&[a, b]).unwrap();
assert_eq!(store.unique_objects(), 1);
assert_eq!(store.logical_spill_refs(), 2);
assert_eq!(
store.unique_offloaded_chars(),
12,
"unique chars counted once"
);
assert_eq!(
store.logical_offloaded_chars(),
24,
"logical chars re-count the dedup ref"
);
}
#[test]
fn a_foreign_session_cid_is_indistinguishable_from_unknown() {
let mine = SessionSpillStore::new(NONCE_A);
let foreign = SpillCid::of(&tool_record(NONCE_B, "foreign secret")).unwrap();
let unknown = SpillCid::of(&tool_record(NONCE_A, "never committed")).unwrap();
assert_eq!(mine.fetch(&foreign), None, "foreign-session CID: not found");
assert_eq!(mine.fetch(&unknown), None, "unknown CID: not found");
assert_eq!(
mine.fetch(&foreign),
mine.fetch(&unknown),
"identical externally-visible result — no existence leak"
);
}
#[test]
fn a_planted_secret_never_appears_in_canonical_bytes_or_stored_record() {
const SECRET: &str = "sk-PLANTEDsecret0123456789";
let redacted = "head [REDACTED] tail".to_string();
assert!(!redacted.contains(SECRET));
let record = tool_record(NONCE_A, &redacted);
let bytes = record.canonical_form().unwrap();
assert!(
!String::from_utf8_lossy(&bytes).contains(SECRET),
"raw secret absent from canonical CID input bytes"
);
let store = SessionSpillStore::new(NONCE_A);
let cid = store.store(record).unwrap();
let got = store.fetch(&cid).unwrap();
assert!(
!got.redacted_text.contains(SECRET),
"raw secret absent from the stored record"
);
assert!(
!cid.to_handle().contains(SECRET),
"raw secret absent from the handle"
);
}
#[test]
fn maybe_offload_off_path_is_bit_for_bit() {
let big = "x".repeat(TOOL_RESULT_SPILL_CAP + 1);
let store = SessionSpillStore::new(NONCE_A);
assert_eq!(maybe_offload(big.clone(), false, None, Some(&store)), big);
assert_eq!(store.unique_objects(), 0);
assert_eq!(maybe_offload(big.clone(), true, None, None), big);
let small = "x".repeat(TOOL_RESULT_SPILL_CAP); assert_eq!(
maybe_offload(small.clone(), true, None, Some(&store)),
small
);
assert_eq!(store.unique_objects(), 0);
}
#[test]
fn maybe_offload_over_cap_emits_a_content_handle_that_resolves() {
let store = SessionSpillStore::new(NONCE_A);
let big = "x".repeat(TOOL_RESULT_SPILL_CAP + 1);
let out = maybe_offload(big.clone(), true, Some("read_file".into()), Some(&store));
assert!(out.contains("spill:"), "teaser carries a handle: {out:.80}");
assert!(out.contains("memory_fetch"), "teaser coaches re-read");
assert!(
out.chars().count() < big.chars().count(),
"teaser is shorter"
);
assert_eq!(store.unique_objects(), 1);
let cid = SpillCid::of(&SpillRecordV1::new(
SpillScope::Session(NONCE_A),
SpillProvenance::ToolOutput {
tool_name: Some("read_file".into()),
},
big.clone(),
))
.unwrap();
assert!(out.contains(&format!("spill:{}", cid.to_handle())));
let arguments = serde_json::json!({"address": format!("spill:{}", cid.to_handle())});
assert!(
out.contains(&format!(
"memory_fetch tool with JSON arguments {arguments}"
)),
"teaser must name the real tool and its address parameter: {out}"
);
assert!(
!out.contains("memory_fetch("),
"teaser must not teach a pseudo-call that can be mistaken for a URL"
);
assert_eq!(store.fetch(&cid).unwrap().redacted_text, big);
}
#[test]
fn maybe_offload_redacts_before_store_and_in_teaser() {
let secret = "sk-ABCDEFGHIJKLMNOPQRST0123";
let payload = format!(
"{}\n{secret}\n{}",
"head ".repeat(2_000),
"tail ".repeat(2_000)
);
assert!(payload.chars().count() > TOOL_RESULT_SPILL_CAP);
let store = SessionSpillStore::new(NONCE_A);
let teaser = maybe_offload(payload, true, Some("run_command".into()), Some(&store));
assert!(
!teaser.contains(secret),
"raw secret NOT shown in the teaser"
);
let stored = store.map.lock().unwrap();
let (_, record) = stored.iter().next().expect("one stored object");
assert!(record.redacted_text.contains("[REDACTED]"));
assert!(!record.redacted_text.contains(secret));
}
#[test]
fn maybe_offload_fails_closed_on_commit_failure() {
let store = SessionSpillStore::new(NONCE_A);
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _g = store.map.lock().unwrap();
panic!("intentional poison");
}));
let big = "x".repeat(TOOL_RESULT_SPILL_CAP + 1);
let out = maybe_offload(big, true, Some("read_file".into()), Some(&store));
assert!(!out.contains("spill:"), "no handle from a failed store");
assert!(!out.contains("memory_fetch"), "no retrieval marker");
}
#[test]
fn store_redacted_full_commits_and_fails_closed() {
let store = SessionSpillStore::new(NONCE_A);
let (handle, redacted) =
store_redacted_full("full output body", Some("run_command".into()), &store);
let handle = handle.expect("committed → Some(handle)");
let cid = SpillCid::parse(&handle).expect("handle is a canonical CID");
assert_eq!(store.fetch(&cid).unwrap().redacted_text, redacted);
let poisoned = SessionSpillStore::new(NONCE_A);
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _g = poisoned.map.lock().unwrap();
panic!("intentional poison");
}));
let (none, _) = store_redacted_full("x", Some("run_command".into()), &poisoned);
assert_eq!(none, None, "a failed store yields no handle");
}
}