use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const DEFAULT_CAP_BYTES: u64 = 512 * 1024;
const RECENT_WINDOW_SECS: u64 = 24 * 60 * 60;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrictionEntry {
pub ts: u64,
pub surface: String,
pub verb: String,
pub code: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
pub fn closed_reason(code: &str, details: Option<&serde_json::Value>) -> Option<&'static str> {
let vocab: &[&'static str] = match code {
"INVALID_TITLE" => &["invalid_chars", "control_chars", "id_too_long", "empty"],
"MEM_PATH_NOT_ALLOWED" => &["no_allowlist_configured", "no_match", "outside_workspace"],
_ => return None,
};
let candidate = details?.get("reason")?.as_str()?;
vocab.iter().find(|v| **v == candidate).copied()
}
#[derive(Debug, Clone)]
pub struct FrictionLedger {
path: PathBuf,
cap_bytes: u64,
}
fn friction_dir(workspace_root: &Path) -> PathBuf {
workspace_root
.join(crate::workspace_store::WORKSPACE_STORE_DIR)
.join("state")
.join("friction")
}
pub fn friction_ledger_path(workspace_root: &Path) -> PathBuf {
friction_dir(workspace_root).join("refusals.jsonl")
}
impl FrictionLedger {
pub fn for_workspace(workspace_root: &Path) -> Self {
Self {
path: friction_ledger_path(workspace_root),
cap_bytes: DEFAULT_CAP_BYTES,
}
}
pub fn at_path(path: PathBuf, cap_bytes: u64) -> Self {
Self { path, cap_bytes }
}
pub fn record(&self, surface: &str, verb: &str, code: &str, reason: Option<&'static str>) {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default();
let entry = FrictionEntry {
ts,
surface: surface.to_string(),
verb: verb.to_string(),
code: code.to_string(),
reason: reason.map(str::to_string),
};
let Ok(mut line) = serde_json::to_vec(&entry) else {
return;
};
line.push(b'\n');
let Some(dir) = self.path.parent() else {
return;
};
if std::fs::create_dir_all(dir).is_err() {
return;
}
let gitignore = dir.join(".gitignore");
if !gitignore.exists() {
let _ = std::fs::write(&gitignore, "*\n");
}
if let Ok(meta) = std::fs::metadata(&self.path)
&& meta.len() >= self.cap_bytes
{
let _ = std::fs::rename(&self.path, self.rotated_path());
}
let Ok(mut file) = std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(&self.path)
else {
return;
};
let _ = file.write_all(&line);
}
fn rotated_path(&self) -> PathBuf {
let mut name = self
.path
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
name.push(".1");
self.path.with_file_name(name)
}
pub fn total_bytes(&self) -> u64 {
let len = |p: &Path| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
len(&self.path) + len(&self.rotated_path())
}
pub fn entries(&self) -> Vec<FrictionEntry> {
let mut out = Vec::new();
for p in [self.rotated_path(), self.path.clone()] {
if let Ok(content) = std::fs::read_to_string(&p) {
for l in content.lines() {
if let Ok(e) = serde_json::from_str::<FrictionEntry>(l) {
out.push(e);
}
}
}
}
out
}
pub fn summarize(&self) -> serde_json::Value {
let entries = self.entries();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default();
let cutoff = now.saturating_sub(RECENT_WINDOW_SECS);
let mut by_code: BTreeMap<String, u64> = BTreeMap::new();
let mut by_verb: BTreeMap<String, u64> = BTreeMap::new();
let mut by_reason: BTreeMap<String, BTreeMap<String, u64>> = BTreeMap::new();
let mut recent_by_code: BTreeMap<String, u64> = BTreeMap::new();
let mut recent_total = 0u64;
for e in &entries {
*by_code.entry(e.code.clone()).or_default() += 1;
*by_verb
.entry(format!("{}:{}", e.surface, e.verb))
.or_default() += 1;
if let Some(reason) = &e.reason {
*by_reason
.entry(e.code.clone())
.or_default()
.entry(reason.clone())
.or_default() += 1;
}
if e.ts >= cutoff {
recent_total += 1;
*recent_by_code.entry(e.code.clone()).or_default() += 1;
}
}
serde_json::json!({
"total": entries.len(),
"by_code": by_code,
"by_verb": by_verb,
"by_reason": by_reason,
"recent_24h": {
"total": recent_total,
"by_code": recent_by_code,
},
"ledger_bytes": self.total_bytes(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn record_appends_and_summarize_counts() {
let tmp = TempDir::new().unwrap();
let ledger = FrictionLedger::for_workspace(tmp.path());
ledger.record("cli", "create", "UNKNOWN_SECTION", None);
ledger.record("mcp", "memstead_create", "UNKNOWN_SECTION", None);
ledger.record("cli", "relate", "INVALID_REL_TYPE", None);
let s = ledger.summarize();
assert_eq!(s["total"], 3);
assert_eq!(s["by_code"]["UNKNOWN_SECTION"], 2);
assert_eq!(s["by_code"]["INVALID_REL_TYPE"], 1);
assert_eq!(s["by_verb"]["cli:create"], 1);
assert_eq!(s["by_verb"]["mcp:memstead_create"], 1);
assert_eq!(s["recent_24h"]["total"], 3);
}
#[test]
fn size_bound_holds_under_refusal_loop() {
let tmp = TempDir::new().unwrap();
let cap = 2048u64;
let ledger = FrictionLedger::at_path(tmp.path().join("refusals.jsonl"), cap);
for i in 0..2000 {
ledger.record("cli", "create", &format!("CODE_{}", i % 7), None);
}
let total = ledger.total_bytes();
assert!(
total <= 2 * cap + 256,
"ledger grew past its bound: {total} bytes (cap {cap})"
);
let s = ledger.summarize();
assert!(s["total"].as_u64().unwrap() > 0);
}
#[test]
fn ledger_dir_is_self_ignoring() {
let tmp = TempDir::new().unwrap();
let ledger = FrictionLedger::for_workspace(tmp.path());
ledger.record("cli", "create", "UNKNOWN_SECTION", None);
let gitignore = friction_dir(tmp.path()).join(".gitignore");
assert_eq!(std::fs::read_to_string(gitignore).unwrap(), "*\n");
}
#[test]
#[cfg(unix)]
fn unwritable_dir_degrades_to_not_recording() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let sealed = tmp.path().join("sealed");
std::fs::create_dir_all(&sealed).unwrap();
std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o555)).unwrap();
let ledger = FrictionLedger::at_path(sealed.join("sub").join("refusals.jsonl"), 1024);
ledger.record("cli", "create", "UNKNOWN_SECTION", None);
assert_eq!(ledger.entries().len(), 0);
std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
}
#[test]
fn concurrent_appends_never_tear_lines() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("refusals.jsonl");
let per_thread = 200;
let threads: Vec<_> = (0..4)
.map(|t| {
let ledger = FrictionLedger::at_path(path.clone(), u64::MAX);
std::thread::spawn(move || {
for i in 0..per_thread {
ledger.record("mcp", &format!("verb_{t}"), &format!("CODE_{i}"), None);
}
})
})
.collect();
for t in threads {
t.join().unwrap();
}
let content = std::fs::read_to_string(&path).unwrap();
let mut parsed = 0;
for line in content.lines() {
serde_json::from_str::<FrictionEntry>(line)
.unwrap_or_else(|e| panic!("torn/merged ledger line: {e}: {line:?}"));
parsed += 1;
}
assert_eq!(parsed, 4 * per_thread, "no entry lost or merged");
}
#[test]
fn reasons_recorded_and_summarized_for_closed_vocab_codes() {
let tmp = TempDir::new().unwrap();
let ledger = FrictionLedger::for_workspace(tmp.path());
let title_details = serde_json::json!({ "reason": "invalid_chars", "input": "x" });
let path_details = serde_json::json!({ "reason": "no_match", "candidate": "y" });
ledger.record(
"cli",
"create",
"INVALID_TITLE",
closed_reason("INVALID_TITLE", Some(&title_details)),
);
ledger.record(
"cli",
"create",
"INVALID_TITLE",
closed_reason("INVALID_TITLE", Some(&title_details)),
);
ledger.record(
"mcp",
"memstead_mem_create",
"MEM_PATH_NOT_ALLOWED",
closed_reason("MEM_PATH_NOT_ALLOWED", Some(&path_details)),
);
ledger.record("cli", "update", "UNKNOWN_SECTION", None);
let s = ledger.summarize();
assert_eq!(s["by_reason"]["INVALID_TITLE"]["invalid_chars"], 2);
assert_eq!(s["by_reason"]["MEM_PATH_NOT_ALLOWED"]["no_match"], 1);
assert!(s["by_reason"].get("UNKNOWN_SECTION").is_none());
assert_eq!(s["by_code"]["UNKNOWN_SECTION"], 1);
}
#[test]
fn entry_without_reason_omits_the_field() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("refusals.jsonl");
let ledger = FrictionLedger::at_path(path.clone(), u64::MAX);
ledger.record("cli", "create", "UNKNOWN_SECTION", None);
let content = std::fs::read_to_string(&path).unwrap();
assert!(
!content.contains("reason"),
"reason key must be absent, got: {content}"
);
}
#[test]
fn closed_reason_rejects_unlisted_values_and_codes() {
let attacker = serde_json::json!({ "reason": "caller-supplied /etc/passwd" });
assert_eq!(closed_reason("INVALID_TITLE", Some(&attacker)), None);
let open_ended = serde_json::json!({ "reason": "must not carry a version or range" });
assert_eq!(closed_reason("CONFIG_ERROR", Some(&open_ended)), None);
assert_eq!(closed_reason("INVALID_TITLE", None), None);
let ok = serde_json::json!({ "reason": "id_too_long" });
let got: Option<&'static str> = closed_reason("INVALID_TITLE", Some(&ok));
assert_eq!(got, Some("id_too_long"));
}
#[test]
fn pre_change_entries_parse_and_count_across_generations() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("refusals.jsonl");
std::fs::write(
tmp.path().join("refusals.jsonl.1"),
"{\"ts\":100,\"surface\":\"cli\",\"verb\":\"mem\",\"code\":\"MEM_PATH_NOT_ALLOWED\"}\n",
)
.unwrap();
std::fs::write(
&path,
"{\"ts\":200,\"surface\":\"cli\",\"verb\":\"create\",\"code\":\"INVALID_TITLE\"}\n",
)
.unwrap();
let ledger = FrictionLedger::at_path(path, u64::MAX);
ledger.record("cli", "create", "INVALID_TITLE", Some("empty"));
let entries = ledger.entries();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].reason, None);
assert_eq!(entries[1].reason, None);
assert_eq!(entries[2].reason.as_deref(), Some("empty"));
let s = ledger.summarize();
assert_eq!(s["total"], 3);
assert_eq!(s["by_code"]["INVALID_TITLE"], 2);
assert_eq!(s["by_code"]["MEM_PATH_NOT_ALLOWED"], 1);
assert_eq!(s["by_reason"]["INVALID_TITLE"]["empty"], 1);
}
}