Skip to main content

memstead_base/
friction.rs

1//! Friction ledger — the engine's record of its own surface's
2//! learnability (agent-trust plan 08).
3//!
4//! Every typed refusal a surface returns is appended as one JSONL line
5//! to a workspace-local, gitignored, size-bounded ledger under
6//! `.memstead/state/friction/`. The ledger answers "which refusal
7//! codes do agents actually hit, on which verbs, how often" as a query
8//! instead of an anecdote — the evidence substrate for surface-design
9//! changes.
10//!
11//! ## Hard lines (recorded contract)
12//!
13//! - **Privacy**: every recorded field's value space is a closed,
14//!   engine-defined vocabulary — values that exist as literals in
15//!   engine source (`"cli"`/`"mcp"`, the tool/subcommand name, the
16//!   UPPER_SNAKE_CASE refusal code, the per-code reason
17//!   discriminators in [`closed_reason`]) plus the epoch-seconds
18//!   timestamp. Never parameters, entity ids, message text, free-form
19//!   strings, or any payload content — a candidate field whose values
20//!   a caller can influence is out of bounds no matter how useful.
21//!   The write path enforces this by type: reasons enter only as
22//!   `&'static str` drawn from the vocabulary table.
23//! - **Local only, forever**: the ledger never leaves the machine —
24//!   no transmission, no registry involvement. The read surface is
25//!   `memstead health --include friction` (and the MCP counterpart).
26//! - **Refusals only**: successful operations are never recorded —
27//!   this is a friction ledger, not telemetry.
28//! - **Best-effort, never perturbing**: recording must not affect the
29//!   refusal path. Every ledger I/O error is swallowed; the refusal
30//!   returns unchanged whether or not the append landed (an unwritable
31//!   state dir degrades to not-recording).
32//!
33//! ## Concurrency and bound
34//!
35//! Appends are one `write` syscall of one complete line on an
36//! `O_APPEND` handle — concurrent writers (a CLI invocation beside a
37//! running MCP server, the normal state of a live workspace) interleave
38//! whole lines, never tear them. The bound is rotation: when the
39//! current file reaches the cap it is renamed to `<name>.1` (replacing
40//! the previous generation), so at most ~2× cap bytes exist on disk. A
41//! concurrent rotation race loses at worst the rename (swallowed) —
42//! entries keep landing in whichever generation the writer's handle
43//! points at, every line still complete.
44
45use std::collections::BTreeMap;
46use std::io::Write;
47use std::path::{Path, PathBuf};
48
49use serde::{Deserialize, Serialize};
50
51/// Rotation threshold for the current generation. At ~80 bytes per
52/// entry this holds >6k refusals per generation — months of normal
53/// friction — while bounding the ledger to ~1 MiB across both
54/// generations.
55pub const DEFAULT_CAP_BYTES: u64 = 512 * 1024;
56
57/// Seconds in the "recent" summary window (24 hours).
58const RECENT_WINDOW_SECS: u64 = 24 * 60 * 60;
59
60/// One ledger line. Every field's value space follows the module's
61/// closed-vocabulary rule (see the privacy hard line above) — that
62/// rule, not this struct's current shape, is the contract.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct FrictionEntry {
65    /// Unix epoch seconds at record time.
66    pub ts: u64,
67    /// Which surface returned the refusal: `"cli"` or `"mcp"`.
68    pub surface: String,
69    /// The verb the caller invoked: MCP tool name (`memstead_create`)
70    /// or CLI subcommand (`create`).
71    pub verb: String,
72    /// The typed refusal code (`UNKNOWN_SECTION`, `HASH_MISMATCH`, …).
73    pub code: String,
74    /// The refusal's closed engine-owned reason discriminator, for
75    /// the codes that compute one (see [`closed_reason`]) — absent
76    /// otherwise, never an empty string or placeholder. Entries
77    /// written before the field existed deserialize as `None`.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub reason: Option<String>,
80}
81
82/// The per-code closed reason vocabularies, and the only gate through
83/// which a reason reaches the ledger. Given a refusal's `code` and its
84/// structured `details`, returns the matching engine-source literal
85/// when — and only when — the code has a declared vocabulary AND the
86/// details' `reason` value is a member. The return is the vocabulary's
87/// own `&'static str`, never the input string, so a caller-influenced
88/// value can only ever select from (not extend) the closed set; any
89/// other value records nothing.
90///
91/// Adding a code here requires its `details.reason` to be a closed,
92/// engine-defined discriminator (computed at refusal time from engine
93/// state, e.g. `SlugError::reason()`) — an open-ended or
94/// caller-derived `details.reason` must NOT be listed.
95pub fn closed_reason(code: &str, details: Option<&serde_json::Value>) -> Option<&'static str> {
96    let vocab: &[&'static str] = match code {
97        "INVALID_TITLE" => &["invalid_chars", "control_chars", "id_too_long", "empty"],
98        "MEM_PATH_NOT_ALLOWED" => &["no_allowlist_configured", "no_match", "outside_workspace"],
99        _ => return None,
100    };
101    let candidate = details?.get("reason")?.as_str()?;
102    vocab.iter().find(|v| **v == candidate).copied()
103}
104
105/// Append-side handle. Cheap to construct per refusal — no state
106/// beyond the target path and the cap.
107#[derive(Debug, Clone)]
108pub struct FrictionLedger {
109    path: PathBuf,
110    cap_bytes: u64,
111}
112
113/// The ledger's directory under the workspace store:
114/// `<root>/.memstead/state/friction/`.
115fn friction_dir(workspace_root: &Path) -> PathBuf {
116    workspace_root
117        .join(crate::workspace_store::WORKSPACE_STORE_DIR)
118        .join("state")
119        .join("friction")
120}
121
122/// The current-generation ledger file path for a workspace.
123pub fn friction_ledger_path(workspace_root: &Path) -> PathBuf {
124    friction_dir(workspace_root).join("refusals.jsonl")
125}
126
127impl FrictionLedger {
128    /// The workspace's ledger with the default cap.
129    pub fn for_workspace(workspace_root: &Path) -> Self {
130        Self {
131            path: friction_ledger_path(workspace_root),
132            cap_bytes: DEFAULT_CAP_BYTES,
133        }
134    }
135
136    /// A ledger at an explicit path with an explicit cap — the test
137    /// constructor (the bound assertion drives a tiny cap).
138    pub fn at_path(path: PathBuf, cap_bytes: u64) -> Self {
139        Self { path, cap_bytes }
140    }
141
142    /// Append one refusal. Best-effort by contract: every failure —
143    /// unwritable dir, full disk, rename race — is swallowed, and the
144    /// caller's refusal path proceeds unchanged. Records only values
145    /// from closed engine-defined vocabularies (module hard line);
146    /// `reason` is `&'static str` by design — the only way to pass one
147    /// is an engine-source literal, normally [`closed_reason`]'s
148    /// return. A refusal whose reason cannot be determined records
149    /// with `None` rather than not recording.
150    pub fn record(&self, surface: &str, verb: &str, code: &str, reason: Option<&'static str>) {
151        let ts = std::time::SystemTime::now()
152            .duration_since(std::time::UNIX_EPOCH)
153            .map(|d| d.as_secs())
154            .unwrap_or_default();
155        let entry = FrictionEntry {
156            ts,
157            surface: surface.to_string(),
158            verb: verb.to_string(),
159            code: code.to_string(),
160            reason: reason.map(str::to_string),
161        };
162        let Ok(mut line) = serde_json::to_vec(&entry) else {
163            return;
164        };
165        line.push(b'\n');
166
167        let Some(dir) = self.path.parent() else {
168            return;
169        };
170        if std::fs::create_dir_all(dir).is_err() {
171            return;
172        }
173        // Self-ignoring subtree, same convention as the findings /
174        // advance stores: per-checkout engine residue inside a
175        // possibly-tracked workspace never surfaces as git noise.
176        let gitignore = dir.join(".gitignore");
177        if !gitignore.exists() {
178            let _ = std::fs::write(&gitignore, "*\n");
179        }
180
181        // Size bound: rotate the full current generation aside
182        // (replacing the previous one) before appending. A concurrent
183        // rotation race loses the rename, which is swallowed — every
184        // already-written line survives in one generation or the other.
185        if let Ok(meta) = std::fs::metadata(&self.path)
186            && meta.len() >= self.cap_bytes
187        {
188            let _ = std::fs::rename(&self.path, self.rotated_path());
189        }
190
191        // One O_APPEND handle, one write_all of one complete line —
192        // the whole-line atomicity concurrent writers rely on.
193        let Ok(mut file) = std::fs::OpenOptions::new()
194            .append(true)
195            .create(true)
196            .open(&self.path)
197        else {
198            return;
199        };
200        let _ = file.write_all(&line);
201    }
202
203    /// The previous-generation path (`refusals.jsonl.1`).
204    fn rotated_path(&self) -> PathBuf {
205        let mut name = self
206            .path
207            .file_name()
208            .map(|n| n.to_os_string())
209            .unwrap_or_default();
210        name.push(".1");
211        self.path.with_file_name(name)
212    }
213
214    /// Total bytes currently on disk across both generations — the
215    /// observable the bound test asserts against.
216    pub fn total_bytes(&self) -> u64 {
217        let len = |p: &Path| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
218        len(&self.path) + len(&self.rotated_path())
219    }
220
221    /// Read every parseable entry across both generations, oldest
222    /// generation first. Unparseable lines are skipped (the summary is
223    /// tolerant; the concurrency test asserts none exist).
224    pub fn entries(&self) -> Vec<FrictionEntry> {
225        let mut out = Vec::new();
226        for p in [self.rotated_path(), self.path.clone()] {
227            if let Ok(content) = std::fs::read_to_string(&p) {
228                for l in content.lines() {
229                    if let Ok(e) = serde_json::from_str::<FrictionEntry>(l) {
230                        out.push(e);
231                    }
232                }
233            }
234        }
235        out
236    }
237
238    /// The `include=["friction"]` health axis: counts per code and per
239    /// verb over the whole ledger, plus the same for the recent 24h
240    /// window. Shared by the CLI health command and both MCP flavours
241    /// so the axis cannot drift between surfaces.
242    pub fn summarize(&self) -> serde_json::Value {
243        let entries = self.entries();
244        let now = std::time::SystemTime::now()
245            .duration_since(std::time::UNIX_EPOCH)
246            .map(|d| d.as_secs())
247            .unwrap_or_default();
248        let cutoff = now.saturating_sub(RECENT_WINDOW_SECS);
249
250        let mut by_code: BTreeMap<String, u64> = BTreeMap::new();
251        let mut by_verb: BTreeMap<String, u64> = BTreeMap::new();
252        // Per-code reason breakdown — only codes with at least one
253        // recorded reason appear; codes without reasons report through
254        // `by_code` exactly as before.
255        let mut by_reason: BTreeMap<String, BTreeMap<String, u64>> = BTreeMap::new();
256        let mut recent_by_code: BTreeMap<String, u64> = BTreeMap::new();
257        let mut recent_total = 0u64;
258        for e in &entries {
259            *by_code.entry(e.code.clone()).or_default() += 1;
260            *by_verb
261                .entry(format!("{}:{}", e.surface, e.verb))
262                .or_default() += 1;
263            if let Some(reason) = &e.reason {
264                *by_reason
265                    .entry(e.code.clone())
266                    .or_default()
267                    .entry(reason.clone())
268                    .or_default() += 1;
269            }
270            if e.ts >= cutoff {
271                recent_total += 1;
272                *recent_by_code.entry(e.code.clone()).or_default() += 1;
273            }
274        }
275        serde_json::json!({
276            "total": entries.len(),
277            "by_code": by_code,
278            "by_verb": by_verb,
279            "by_reason": by_reason,
280            "recent_24h": {
281                "total": recent_total,
282                "by_code": recent_by_code,
283            },
284            "ledger_bytes": self.total_bytes(),
285        })
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use tempfile::TempDir;
293
294    #[test]
295    fn record_appends_and_summarize_counts() {
296        let tmp = TempDir::new().unwrap();
297        let ledger = FrictionLedger::for_workspace(tmp.path());
298        ledger.record("cli", "create", "UNKNOWN_SECTION", None);
299        ledger.record("mcp", "memstead_create", "UNKNOWN_SECTION", None);
300        ledger.record("cli", "relate", "INVALID_REL_TYPE", None);
301        let s = ledger.summarize();
302        assert_eq!(s["total"], 3);
303        assert_eq!(s["by_code"]["UNKNOWN_SECTION"], 2);
304        assert_eq!(s["by_code"]["INVALID_REL_TYPE"], 1);
305        assert_eq!(s["by_verb"]["cli:create"], 1);
306        assert_eq!(s["by_verb"]["mcp:memstead_create"], 1);
307        assert_eq!(s["recent_24h"]["total"], 3);
308    }
309
310    /// The size bound holds under a loop of refusals: total on-disk
311    /// bytes across both generations never exceed ~2× the cap plus one
312    /// entry of slack.
313    #[test]
314    fn size_bound_holds_under_refusal_loop() {
315        let tmp = TempDir::new().unwrap();
316        let cap = 2048u64;
317        let ledger = FrictionLedger::at_path(tmp.path().join("refusals.jsonl"), cap);
318        for i in 0..2000 {
319            ledger.record("cli", "create", &format!("CODE_{}", i % 7), None);
320        }
321        let total = ledger.total_bytes();
322        assert!(
323            total <= 2 * cap + 256,
324            "ledger grew past its bound: {total} bytes (cap {cap})"
325        );
326        // Rotation kept parseable content — the summary still serves.
327        let s = ledger.summarize();
328        assert!(s["total"].as_u64().unwrap() > 0);
329    }
330
331    /// The self-ignoring `.gitignore` lands beside the ledger.
332    #[test]
333    fn ledger_dir_is_self_ignoring() {
334        let tmp = TempDir::new().unwrap();
335        let ledger = FrictionLedger::for_workspace(tmp.path());
336        ledger.record("cli", "create", "UNKNOWN_SECTION", None);
337        let gitignore = friction_dir(tmp.path()).join(".gitignore");
338        assert_eq!(std::fs::read_to_string(gitignore).unwrap(), "*\n");
339    }
340
341    /// An unwritable ledger location degrades to not-recording and
342    /// never panics or errors — best-effort by contract.
343    #[test]
344    #[cfg(unix)]
345    fn unwritable_dir_degrades_to_not_recording() {
346        use std::os::unix::fs::PermissionsExt;
347        let tmp = TempDir::new().unwrap();
348        let sealed = tmp.path().join("sealed");
349        std::fs::create_dir_all(&sealed).unwrap();
350        std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o555)).unwrap();
351        let ledger = FrictionLedger::at_path(sealed.join("sub").join("refusals.jsonl"), 1024);
352        ledger.record("cli", "create", "UNKNOWN_SECTION", None);
353        assert_eq!(ledger.entries().len(), 0);
354        std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
355    }
356
357    /// Concurrent writers (the CLI beside a running MCP server)
358    /// interleave whole lines, never torn or merged ones: after a
359    /// concurrent-append burst every line parses as a complete record
360    /// and no entry was lost.
361    #[test]
362    fn concurrent_appends_never_tear_lines() {
363        let tmp = TempDir::new().unwrap();
364        let path = tmp.path().join("refusals.jsonl");
365        let per_thread = 200;
366        let threads: Vec<_> = (0..4)
367            .map(|t| {
368                let ledger = FrictionLedger::at_path(path.clone(), u64::MAX);
369                std::thread::spawn(move || {
370                    for i in 0..per_thread {
371                        ledger.record("mcp", &format!("verb_{t}"), &format!("CODE_{i}"), None);
372                    }
373                })
374            })
375            .collect();
376        for t in threads {
377            t.join().unwrap();
378        }
379        let content = std::fs::read_to_string(&path).unwrap();
380        let mut parsed = 0;
381        for line in content.lines() {
382            serde_json::from_str::<FrictionEntry>(line)
383                .unwrap_or_else(|e| panic!("torn/merged ledger line: {e}: {line:?}"));
384            parsed += 1;
385        }
386        assert_eq!(parsed, 4 * per_thread, "no entry lost or merged");
387    }
388
389    /// A refusal with a closed engine-owned discriminator records it,
390    /// and the summary breaks the code's count down by reason —
391    /// verified for two distinct codes.
392    #[test]
393    fn reasons_recorded_and_summarized_for_closed_vocab_codes() {
394        let tmp = TempDir::new().unwrap();
395        let ledger = FrictionLedger::for_workspace(tmp.path());
396        let title_details = serde_json::json!({ "reason": "invalid_chars", "input": "x" });
397        let path_details = serde_json::json!({ "reason": "no_match", "candidate": "y" });
398        ledger.record(
399            "cli",
400            "create",
401            "INVALID_TITLE",
402            closed_reason("INVALID_TITLE", Some(&title_details)),
403        );
404        ledger.record(
405            "cli",
406            "create",
407            "INVALID_TITLE",
408            closed_reason("INVALID_TITLE", Some(&title_details)),
409        );
410        ledger.record(
411            "mcp",
412            "memstead_mem_create",
413            "MEM_PATH_NOT_ALLOWED",
414            closed_reason("MEM_PATH_NOT_ALLOWED", Some(&path_details)),
415        );
416        ledger.record("cli", "update", "UNKNOWN_SECTION", None);
417
418        let s = ledger.summarize();
419        assert_eq!(s["by_reason"]["INVALID_TITLE"]["invalid_chars"], 2);
420        assert_eq!(s["by_reason"]["MEM_PATH_NOT_ALLOWED"]["no_match"], 1);
421        // A code without recorded reasons reports through by_code only.
422        assert!(s["by_reason"].get("UNKNOWN_SECTION").is_none());
423        assert_eq!(s["by_code"]["UNKNOWN_SECTION"], 1);
424    }
425
426    /// No reason ⇒ no field: the serialized line omits `reason`
427    /// entirely — not an empty string, not a placeholder.
428    #[test]
429    fn entry_without_reason_omits_the_field() {
430        let tmp = TempDir::new().unwrap();
431        let path = tmp.path().join("refusals.jsonl");
432        let ledger = FrictionLedger::at_path(path.clone(), u64::MAX);
433        ledger.record("cli", "create", "UNKNOWN_SECTION", None);
434        let content = std::fs::read_to_string(&path).unwrap();
435        assert!(
436            !content.contains("reason"),
437            "reason key must be absent, got: {content}"
438        );
439    }
440
441    /// The closed-vocabulary gate: values outside a code's declared
442    /// vocabulary — including caller-influenced strings arriving via
443    /// `details.reason` — and codes with no vocabulary yield `None`,
444    /// so nothing outside engine-source literals can reach the ledger.
445    #[test]
446    fn closed_reason_rejects_unlisted_values_and_codes() {
447        let attacker = serde_json::json!({ "reason": "caller-supplied /etc/passwd" });
448        assert_eq!(closed_reason("INVALID_TITLE", Some(&attacker)), None);
449        // A code whose details carry an open-ended `reason` string is
450        // not in the table — nothing records even though the key exists.
451        let open_ended = serde_json::json!({ "reason": "must not carry a version or range" });
452        assert_eq!(closed_reason("CONFIG_ERROR", Some(&open_ended)), None);
453        assert_eq!(closed_reason("INVALID_TITLE", None), None);
454        // Members pass, and the returned value is the vocabulary's own
455        // static, usable as `&'static str`.
456        let ok = serde_json::json!({ "reason": "id_too_long" });
457        let got: Option<&'static str> = closed_reason("INVALID_TITLE", Some(&ok));
458        assert_eq!(got, Some("id_too_long"));
459    }
460
461    /// Pre-change ledger lines (no `reason` key) parse, count, and
462    /// summarize mixed with new-form lines in the same file and across
463    /// a rotated generation pair.
464    #[test]
465    fn pre_change_entries_parse_and_count_across_generations() {
466        let tmp = TempDir::new().unwrap();
467        let path = tmp.path().join("refusals.jsonl");
468        // Rotated older generation: pre-change shape only.
469        std::fs::write(
470            tmp.path().join("refusals.jsonl.1"),
471            "{\"ts\":100,\"surface\":\"cli\",\"verb\":\"mem\",\"code\":\"MEM_PATH_NOT_ALLOWED\"}\n",
472        )
473        .unwrap();
474        // Current generation: one pre-change line, then a new-form append.
475        std::fs::write(
476            &path,
477            "{\"ts\":200,\"surface\":\"cli\",\"verb\":\"create\",\"code\":\"INVALID_TITLE\"}\n",
478        )
479        .unwrap();
480        let ledger = FrictionLedger::at_path(path, u64::MAX);
481        ledger.record("cli", "create", "INVALID_TITLE", Some("empty"));
482
483        let entries = ledger.entries();
484        assert_eq!(entries.len(), 3);
485        assert_eq!(entries[0].reason, None);
486        assert_eq!(entries[1].reason, None);
487        assert_eq!(entries[2].reason.as_deref(), Some("empty"));
488        let s = ledger.summarize();
489        assert_eq!(s["total"], 3);
490        assert_eq!(s["by_code"]["INVALID_TITLE"], 2);
491        assert_eq!(s["by_code"]["MEM_PATH_NOT_ALLOWED"], 1);
492        assert_eq!(s["by_reason"]["INVALID_TITLE"]["empty"], 1);
493    }
494}