mecha_core/surface.rs
1//! The tool surface a run was sent, kept once and cited by hash.
2//!
3//! ## The gap this closes
4//!
5//! [`RunConfig`](crate::session::RunConfig) records the system prompt **in
6//! full**, and says why: *"the text lets a replay rebuild the request."* It
7//! records the tool surface as **names**, with a doc comment naming the risk it
8//! saw — *"a tool added, removed or renamed between recording and replay
9//! changes what the model could have done."*
10//!
11//! Add, remove and rename are the three that never happen. **Re-describe is the
12//! one that happens constantly** — 49 commits touched tool definitions in three
13//! weeks of this store — and it is invisible to a list of names. Render order
14//! is tools → system → messages, so a replay was rebuilding the *second* half
15//! of the prefix byte-exactly and the first half from whatever the registry
16//! says today. Every description edit since a recording silently changes the
17//! bytes the model sees before anything else.
18//!
19//! Measured: **12 of 13 counterfactual probes came back inconclusive**, on a
20//! pinned seed and a quiet box, deterministically across repeats — median
21//! divergence one tool call in. Six probes in one session, with steer points
22//! from 10 to 33, all gave up at the same call: a trajectory-dependent cause
23//! cannot do that, and a per-session constant can.
24//!
25//! ## Why a hash and a store rather than the specs inline
26//!
27//! Costed rather than preferred: the specs are **69 KB** against a **25 KB**
28//! average session file, so inlining would quadruple the session store and put,
29//! in most sessions, more bytes of tool description than conversation. A hash
30//! alone is cheap and gives up the rebuild, which is the point of recording it.
31//!
32//! So the specs are written once per distinct surface and cited by hash — the
33//! precedent is `ValidationRecord`'s `rules_hash`, *"keyed to the exact rule
34//! set measured, because a tally that mixes generations measures nothing."*
35//! Surfaces change tens of times over a corpus, not once per session, so the
36//! store dedupes to a few megabytes where inlining would cost tens.
37//!
38//! ## Three states, and the one that matters is `Unknown`
39//!
40//! A names-only recording must never read as *matching*. Every session written
41//! before this field exists — all of them, on the day it lands — is
42//! [`Fidelity::Unknown`], and a probe over one is inconclusive **for a named
43//! reason** instead of mysteriously. That is the whole reason this is
44//! `Option<String>` and not `String`: absent is not equal, the rule
45//! [`crate::homeostat`] and [`crate::backlog`] both state at length.
46//!
47//! [`Fidelity::Differs`] needs no blob at all — comparing today's hash against
48//! the recorded one answers it — so **legibility arrives the day the field
49//! ships and rebuildability accumulates afterwards**. That ordering is worth
50//! more than either half alone: it turns an inconclusive probe from a mystery
51//! into a labelled cause immediately.
52//!
53//! ## What it does not do
54//!
55//! **It does not recover the existing corpus.** Nothing can: those recordings
56//! never held the specs, and the descriptions they were sent are only in git
57//! history that cannot be matched to a session. The appraisal corpus and the
58//! validation ledger start from zero the day this ships, and anyone budgeting
59//! on the sessions already on disk should read that first.
60//!
61//! Nothing here is ever deleted, for the same reason a published bundle is not:
62//! a surface blob is what makes an old session replayable, and a retention
63//! policy over it would quietly cost the recordings it was keeping.
64
65use crate::message::ToolSpec;
66use anyhow::{Context, Result};
67use std::path::{Path, PathBuf};
68
69/// How faithfully a replay can reproduce what a recording was sent.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum Fidelity {
72 /// Today's surface hashes to the recorded value. The replay sends what the
73 /// recording sent.
74 Matches,
75 /// It provably does not, and the difference is in the bytes ahead of the
76 /// system prompt. A replay may still run; its divergences say nothing
77 /// about the question being probed.
78 Differs,
79 /// The recording predates the field, so there is nothing to compare. **Not
80 /// a match** — see the module note.
81 Unknown,
82}
83
84impl Fidelity {
85 /// Compare a recorded hash against a live surface.
86 pub fn of(recorded: Option<&str>, live: &[ToolSpec]) -> Fidelity {
87 match recorded {
88 None => Fidelity::Unknown,
89 Some(h) if h == fingerprint(live) => Fidelity::Matches,
90 Some(_) => Fidelity::Differs,
91 }
92 }
93
94 /// One phrase for a probe's `reason` field, or `None` when there is
95 /// nothing to say.
96 pub fn caveat(self) -> Option<&'static str> {
97 match self {
98 Fidelity::Matches => None,
99 Fidelity::Differs => Some(
100 "the tool surface has changed since this was recorded, so the replay sends \
101 different bytes ahead of the system prompt",
102 ),
103 Fidelity::Unknown => Some(
104 "this was recorded before the tool surface was kept, so how faithfully it \
105 replays is unknown",
106 ),
107 }
108 }
109}
110
111/// A stable identity for one tool surface.
112///
113/// Over the **rendered specs**, not the names — the whole point is that a
114/// re-described tool is a different surface under the same name. Order is the
115/// registry's, which is `BTreeMap` order and therefore stable, and is itself
116/// part of what the model saw: the tool list is the front of the cached prefix.
117///
118/// **`learning::rules_hash`'s hasher, not `DefaultHasher`.** This value is
119/// both a comparison key and a filename, so the caveat that function's own
120/// doc comment states — *"the std hasher is deliberately unstable across
121/// Rust releases, and a ledger key that drifts with the toolchain would
122/// silently split every tally"* — bites harder here: a toolchain bump would
123/// make [`Fidelity::of`] read every session recorded on the old one as
124/// `Differs`, permanently and indistinguishably from real re-describe drift,
125/// and orphan every blob already written in the store this module's own note
126/// says is never pruned. A canonical rendering fed through the same hash
127/// this codebase already trusts for a persisted key, rather than a second
128/// hand-rolled FNV-1a, so there is one definition to keep stable.
129pub fn fingerprint(specs: &[ToolSpec]) -> String {
130 let mut rendered = String::new();
131 for spec in specs {
132 rendered.push_str(&spec.name);
133 rendered.push('\0');
134 rendered.push_str(&spec.description);
135 rendered.push('\0');
136 // `serde_json::Map` is a `BTreeMap`, so this rendering is canonical
137 // whatever order a schema was built in.
138 rendered.push_str(&spec.input_schema.to_string());
139 rendered.push('\0');
140 }
141 crate::learning::rules_hash(&rendered)
142}
143
144/// Where surface blobs live. One file per distinct surface, named by its hash.
145pub struct SurfaceStore {
146 root: PathBuf,
147}
148
149impl SurfaceStore {
150 pub fn default_root() -> Result<PathBuf> {
151 Ok(crate::work::mecha_home()?.join("surfaces"))
152 }
153
154 pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
155 let root = root.into();
156 // Owner-only, like every other store root under `~/.mecha` — the
157 // front door's own rule for the same reason: these blobs are not
158 // nothing. A `ToolSpec` carries the mail account short names baked
159 // into every tool schema as an enum at startup, plus whatever an MCP
160 // server put in its own descriptions.
161 crate::create_private_dir(&root).with_context(|| format!("creating {}", root.display()))?;
162 Ok(SurfaceStore { root })
163 }
164
165 /// Open the default store, or `None` if it cannot be reached.
166 ///
167 /// Best-effort by design: recording a surface is bookkeeping beside a run,
168 /// and a full disk must not stop the run itself. A session that could not
169 /// record its surface carries no hash and reads back as
170 /// [`Fidelity::Unknown`], which is exactly true.
171 pub fn open_default() -> Option<Self> {
172 Self::default_root().ok().and_then(|r| Self::open(r).ok())
173 }
174
175 fn path(&self, hash: &str) -> PathBuf {
176 self.root.join(format!("{hash}.json"))
177 }
178
179 /// Record a surface and return its hash. A surface already on disk costs
180 /// one `exists` and no write.
181 pub fn record(&self, specs: &[ToolSpec]) -> Result<String> {
182 let hash = fingerprint(specs);
183 let path = self.path(&hash);
184 if path.exists() {
185 return Ok(hash);
186 }
187 // Temp-and-rename on the store convention: a crash mid-write must
188 // leave no half-file under a name that claims to be a whole surface.
189 let tmp = self.root.join(format!("{hash}.json.tmp"));
190 std::fs::write(&tmp, serde_json::to_vec_pretty(specs)?)
191 .with_context(|| format!("writing {}", tmp.display()))?;
192 std::fs::rename(&tmp, &path)?;
193 Ok(hash)
194 }
195
196 /// The specs behind a hash, or `None` when the blob is not here.
197 ///
198 /// Absent is not empty: a missing blob means the surface cannot be rebuilt,
199 /// never that the run had no tools.
200 pub fn load(&self, hash: &str) -> Option<Vec<ToolSpec>> {
201 let text = std::fs::read_to_string(self.path(hash)).ok()?;
202 serde_json::from_str(&text).ok()
203 }
204
205 pub fn root(&self) -> &Path {
206 &self.root
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use serde_json::json;
214
215 fn spec(name: &str, description: &str) -> ToolSpec {
216 ToolSpec {
217 name: name.into(),
218 description: description.into(),
219 input_schema: json!({"type": "object"}),
220 }
221 }
222
223 fn scratch() -> SurfaceStore {
224 let dir = std::env::temp_dir().join(format!(
225 "mecha-surface-test-{}-{}",
226 std::process::id(),
227 uuid::Uuid::new_v4()
228 ));
229 SurfaceStore::open(dir).unwrap()
230 }
231
232 /// Owner-only, on the front door's rule for every store root under
233 /// `~/.mecha`: a `ToolSpec` carries the mail account short names baked
234 /// into every tool schema, plus whatever an MCP server put in its own
235 /// descriptions, so this is not nothing to protect.
236 #[test]
237 fn the_surface_store_directory_is_owner_only() {
238 use std::os::unix::fs::PermissionsExt;
239 let dir =
240 std::env::temp_dir().join(format!("mecha-surface-perms-{}", uuid::Uuid::new_v4()));
241 SurfaceStore::open(&dir).unwrap();
242 let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
243 assert_eq!(mode & 0o777, 0o700);
244 std::fs::remove_dir_all(&dir).ok();
245 }
246
247 /// The failure this module exists for: a name list cannot see it.
248 #[test]
249 fn a_re_described_tool_is_a_different_surface() {
250 let before = [spec("fs_read", "Read a file.")];
251 let after = [spec(
252 "fs_read",
253 "Read a file. Paths are workspace-relative.",
254 )];
255 assert_ne!(fingerprint(&before), fingerprint(&after));
256 assert_eq!(
257 before.iter().map(|s| &s.name).collect::<Vec<_>>(),
258 after.iter().map(|s| &s.name).collect::<Vec<_>>(),
259 "…and the names are identical, which is the whole problem"
260 );
261 }
262
263 #[test]
264 fn a_changed_schema_is_a_different_surface_too() {
265 let mut other = spec("todo", "Keep a plan.");
266 other.input_schema = json!({"type": "object", "properties": {"serves": {}}});
267 assert_ne!(
268 fingerprint(&[spec("todo", "Keep a plan.")]),
269 fingerprint(&[other])
270 );
271 }
272
273 #[test]
274 fn the_same_surface_hashes_the_same_twice() {
275 let s = [spec("a", "x"), spec("b", "y")];
276 assert_eq!(fingerprint(&s), fingerprint(&s));
277 // Order is part of the surface: it is the front of the cached prefix.
278 let flipped = [spec("b", "y"), spec("a", "x")];
279 assert_ne!(fingerprint(&s), fingerprint(&flipped));
280 }
281
282 /// The other three tests here compare two values computed in the same
283 /// process, so none of them can fail if this hashed with the *wrong*
284 /// hasher — the mistake this asserts against directly, on
285 /// `the_rules_hash_is_stable_forever`'s precedent one module over. Pinned
286 /// to `learning::rules_hash` over the exact canonical rendering rather
287 /// than a second hex literal, so a change to either the rendering or the
288 /// choice of hasher shows up here rather than only in a toolchain bump
289 /// nobody connects back to this file.
290 #[test]
291 fn fingerprint_uses_the_stable_hasher_not_the_std_one() {
292 let one = spec("build", "Build it.");
293 let expected = crate::learning::rules_hash("build\0Build it.\0{\"type\":\"object\"}\0");
294 assert_eq!(fingerprint(&[one]), expected);
295 }
296
297 /// **The rule the whole design turns on.** Every session on disk the day
298 /// this ships has no hash, and none of them may read as faithful.
299 #[test]
300 fn a_recording_with_no_hash_is_unknown_and_never_a_match() {
301 let live = [spec("fs_read", "Read a file.")];
302 assert_eq!(Fidelity::of(None, &live), Fidelity::Unknown);
303 assert!(Fidelity::Unknown.caveat().is_some());
304 assert_ne!(Fidelity::of(None, &live), Fidelity::Matches);
305 }
306
307 /// And `Differs` needs no blob — which is why legibility lands the day the
308 /// field ships, before any surface has accumulated.
309 #[test]
310 fn drift_is_detectable_with_nothing_but_the_hash() {
311 let recorded = fingerprint(&[spec("fs_read", "Read a file.")]);
312 let live = [spec(
313 "fs_read",
314 "Read a file. Paths are workspace-relative.",
315 )];
316 assert_eq!(Fidelity::of(Some(&recorded), &live), Fidelity::Differs);
317 assert!(Fidelity::Differs.caveat().unwrap().contains("changed"));
318
319 // No store was opened anywhere in this test.
320 let same = [spec("fs_read", "Read a file.")];
321 assert_eq!(Fidelity::of(Some(&recorded), &same), Fidelity::Matches);
322 assert!(Fidelity::Matches.caveat().is_none());
323 }
324
325 #[test]
326 fn a_surface_round_trips_and_a_second_record_writes_nothing_new() {
327 let store = scratch();
328 let specs = vec![spec("a", "one"), spec("b", "two")];
329 let hash = store.record(&specs).unwrap();
330 // Compared by fingerprint rather than by field, which is also the
331 // assertion that matters: what round-trips is the *surface*.
332 assert_eq!(fingerprint(&store.load(&hash).unwrap()), hash);
333
334 let again = store.record(&specs).unwrap();
335 assert_eq!(again, hash);
336 let files = std::fs::read_dir(store.root()).unwrap().count();
337 assert_eq!(files, 1, "one blob per distinct surface, not per record");
338 }
339
340 /// A missing blob is a surface that cannot be rebuilt, never a run that
341 /// had no tools — the distinction every reader over these stores makes.
342 #[test]
343 fn a_missing_blob_is_absent_and_not_empty() {
344 let store = scratch();
345 assert!(store.load("0000000000000000").is_none());
346 }
347}