majordomus_cli/mesh/handover.rs
1//! Handovers across the mesh. A handover is written on one machine by `majordomus
2//! handover` into that checkout's `.ai/local/state/handovers/`; this module is the bridge
3//! between that record and the journal, in both directions:
4//!
5//! - **publish** reads a local record — its front matter's facts and its Markdown body —
6//! into the [`HandoverBody`] a journal event carries, bounded, identified by the digest
7//! of its body;
8//! - **materialize** writes a consumed handover into the consuming checkout's handovers
9//! directory as a record of the same schema, so `majordomus handover --resolve` on a
10//! checkout of the same branch finds it through the ordinary resolution. It names the
11//! repository the way the resolver compares it (the local git common directory) and the
12//! origin runtime as its worktree — another checkout's record on the same branch, tier 1
13//! — and never copies a path of the author's disk.
14//!
15//! Materializing the same handover twice writes one file.
16//!
17//! The publishing half needs nothing but the record on disk, which is what the round trip
18//! below exercises: the newest record of a checkout, read into the bounded body a journal
19//! event carries, identified by the digest of that body and carrying none of the paths of
20//! the machine that wrote it.
21//!
22//! ```
23//! use majordomus_cli::mesh::handover::{directory, latest, to_body};
24//!
25//! let checkout = tempfile::tempdir().unwrap();
26//! let dir = directory(checkout.path());
27//! std::fs::create_dir_all(&dir).unwrap();
28//! std::fs::write(
29//! dir.join("20260915T100000Z--feature-x--abcdef1--0011.md"),
30//! "---\nschema_version: 1\ntask_id: t-1\nbranch: feature/x\nhead: abcdef1234\n\
31//! worktree: /checkout/of/the/author\n---\n\n# Objective\nship\n",
32//! )
33//! .unwrap();
34//!
35//! let newest = latest(checkout.path()).unwrap();
36//! let body = to_body(&newest, Some("#184".into()), None).unwrap();
37//! assert_eq!(body.branch.as_deref(), Some("feature/x"));
38//! assert_eq!(body.issue.as_deref(), Some("#184"), "the publisher's to say");
39//! // the author's disk never travels: the record's worktree is not part of the body
40//! assert!(!serde_json::to_string(&body).unwrap().contains("/checkout/of/the/author"));
41//! ```
42
43use std::collections::BTreeMap;
44use std::path::{Path, PathBuf};
45
46use super::journal::{HandoverBody, MAX_HANDOVER_BYTES};
47use super::state::HandoverView;
48
49/// Where a checkout's handover records live. It is under `.ai/local/`, which is never
50/// committed, so a handover is a fact about one checkout until something — this module, or
51/// a person — moves it; both halves of this module agree on the location by calling here
52/// rather than by each spelling the path.
53///
54/// ```
55/// use majordomus_cli::mesh::handover::directory;
56///
57/// let root = tempfile::tempdir().unwrap();
58/// let dir = directory(root.path());
59/// assert!(dir.ends_with(".ai/local/state/handovers"));
60/// assert!(dir.starts_with(root.path()), "always inside the checkout it belongs to");
61/// ```
62pub fn directory(root: &Path) -> PathBuf {
63 root.join(".ai/local/state/handovers")
64}
65
66/// The newest handover record of a checkout: names begin with a compact UTC timestamp, so
67/// the greatest name is the latest record.
68///
69/// Sorting by name rather than by modification time is deliberate — a file copied or
70/// restored keeps the moment its handover describes, which is the question being asked,
71/// and not the moment the bytes landed. Anything that is not a handover of this checkout
72/// is passed over rather than reported: a file that is not Markdown, one whose name does
73/// not begin with a timestamp, and a symbolic link out of the directory, which is how
74/// something outside the checkout would otherwise get itself published.
75///
76/// ```
77/// use majordomus_cli::mesh::handover::{directory, latest};
78///
79/// let root = tempfile::tempdir().unwrap();
80/// let dir = directory(root.path());
81/// std::fs::create_dir_all(&dir).unwrap();
82/// assert!(latest(root.path()).is_err(), "an empty directory holds no newest record");
83///
84/// std::fs::write(dir.join("20260101T000000Z--a--0000000--00.md"), "---\n---\nold").unwrap();
85/// std::fs::write(dir.join("20260915T100000Z--b--0000000--01.md"), "---\n---\nnew").unwrap();
86/// std::fs::write(dir.join("README.md"), "not a handover").unwrap();
87/// let newest = latest(root.path()).unwrap();
88/// assert!(newest.ends_with("20260915T100000Z--b--0000000--01.md"));
89/// ```
90pub fn latest(root: &Path) -> Result<PathBuf, String> {
91 let dir = directory(root);
92 let real_dir = dir
93 .canonicalize()
94 .map_err(|e| format!("{}: {e}", dir.display()))?;
95 let entries = std::fs::read_dir(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
96 entries
97 .filter_map(|e| e.ok().map(|e| e.path()))
98 .filter(|p| {
99 p.extension().is_some_and(|x| x == "md")
100 && p.file_name()
101 .and_then(|n| n.to_str())
102 .is_some_and(|n| n.as_bytes().first().is_some_and(u8::is_ascii_digit))
103 // A symbolic link out of the directory is not a handover of this checkout:
104 // nothing outside it is ever published.
105 && p.canonicalize().is_ok_and(|real| real.starts_with(&real_dir))
106 })
107 // The name's timestamp is the moment the record describes, to the second; two
108 // records written inside one second differ there only by a random suffix, which
109 // orders nothing. Within one second the later write is the newer record — the
110 // tie the shell resolver breaks by ledger position.
111 .max_by_key(|p| {
112 let name = p
113 .file_name()
114 .and_then(|n| n.to_str())
115 .unwrap_or_default()
116 .to_string();
117 let second = name.split("--").next().unwrap_or_default().to_string();
118 let written = std::fs::metadata(p).and_then(|m| m.modified()).ok();
119 (second, written, name)
120 })
121 .ok_or_else(|| format!("no handover record under {}", dir.display()))
122}
123
124/// A front-matter value from a peer: one line, no quote that could end the value.
125fn one_line(value: &str) -> String {
126 value
127 .chars()
128 .filter(|c| !c.is_control() && *c != '"')
129 .collect()
130}
131
132/// Split a record into its front matter (flat `key: value` lines; list items are skipped)
133/// and its body.
134///
135/// This is a reader of one shape, not a YAML parser: a handover's front matter is flat by
136/// contract, and the few facts the mesh needs from it are scalars. Nesting and list items
137/// are skipped rather than refused, so a record that carries more than this module
138/// understands still publishes.
139///
140/// ```
141/// use majordomus_cli::mesh::handover::read;
142///
143/// let dir = tempfile::tempdir().unwrap();
144/// let path = dir.path().join("record.md");
145/// std::fs::write(
146/// &path,
147/// "---\nbranch: feature/x\nchanged_files:\n - apps/x.rs\n---\n\n# Objective\nship\n",
148/// )
149/// .unwrap();
150///
151/// let (front, body) = read(&path).unwrap();
152/// assert_eq!(front["branch"], "feature/x");
153/// assert!(!front.contains_key("apps/x.rs"), "a list item is not a fact of the front matter");
154/// assert!(body.starts_with("# Objective"));
155///
156/// // a file with no front matter is not a handover record
157/// std::fs::write(&path, "# Objective\nship\n").unwrap();
158/// assert!(read(&path).is_err());
159/// ```
160pub fn read(path: &Path) -> Result<(BTreeMap<String, String>, String), String> {
161 let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
162 split(&text).ok_or_else(|| format!("{}: no front matter", path.display()))
163}
164
165fn split(text: &str) -> Option<(BTreeMap<String, String>, String)> {
166 let rest = text.strip_prefix("---\n")?;
167 let end = rest.find("\n---\n")?;
168 let mut front = BTreeMap::new();
169 for line in rest[..end].lines() {
170 if line.starts_with(' ') || line.starts_with('-') {
171 continue;
172 }
173 if let Some((key, value)) = line.split_once(':') {
174 let value = value.trim().trim_matches('"').to_string();
175 front.insert(key.trim().to_string(), value);
176 }
177 }
178 Some((front, rest[end + 5..].trim_start_matches('\n').to_string()))
179}
180
181/// The journal body of a local record. `issue` and `milestone` are the publisher's to say:
182/// a handover record has no field for either.
183///
184/// What crosses is the body and a few facts about the work — never a path of the author's
185/// disk, because the receiving checkout is somewhere else and a path from another machine
186/// is at best noise. The body is bounded at [`MAX_HANDOVER_BYTES`] and an empty one is
187/// refused, since a handover whose body says nothing hands nothing over. A placeholder
188/// front-matter value (`none`, `DETACHED`) is read as the absence it stands for.
189///
190/// ```
191/// use majordomus_cli::mesh::handover::to_body;
192/// use majordomus_cli::mesh::journal::HandoverBody;
193///
194/// let dir = tempfile::tempdir().unwrap();
195/// let path = dir.path().join("20260915T100000Z--feature-x--abcdef1--0011.md");
196/// std::fs::write(
197/// &path,
198/// "---\ntask_id: t-1\nbranch: DETACHED\nhead: abcdef1234\nworktree: /home/someone/repo\n\
199/// ---\n\n# Objective\nship\n",
200/// )
201/// .unwrap();
202///
203/// let body = to_body(&path, Some("#184".into()), None).unwrap();
204/// assert_eq!(body.task.as_deref(), Some("t-1"));
205/// assert_eq!(body.branch, None, "DETACHED is the absence of a branch, not a branch");
206/// assert_eq!(body.issue.as_deref(), Some("#184"));
207/// assert_eq!(body.id, HandoverBody::digest_of(&body.body));
208/// assert!(!serde_json::to_string(&body).unwrap().contains("/home/someone"));
209///
210/// // a record whose body says nothing hands nothing over
211/// std::fs::write(&path, "---\ntask_id: t-1\n---\n\n").unwrap();
212/// assert!(to_body(&path, None, None).is_err());
213/// ```
214pub fn to_body(
215 path: &Path,
216 issue: Option<String>,
217 milestone: Option<String>,
218) -> Result<HandoverBody, String> {
219 let (front, body) = read(path)?;
220 // signed into the journal and replicated to every linked runtime, in clear: a credential
221 // the author pasted into the record is replaced by a marker before anything is signed
222 let body = crate::redaction::redact_secrets(&body).text;
223 if body.trim().is_empty() {
224 return Err(format!("{}: the handover has no body", path.display()));
225 }
226 if body.len() > MAX_HANDOVER_BYTES {
227 return Err(format!(
228 "{}: a body of {} bytes exceeds the {MAX_HANDOVER_BYTES}-byte bound a mesh handover carries",
229 path.display(),
230 body.len()
231 ));
232 }
233 let fact = |key: &str| {
234 front
235 .get(key)
236 .filter(|v| !v.is_empty() && *v != "none" && *v != "NONE")
237 .cloned()
238 };
239 Ok(HandoverBody {
240 id: HandoverBody::digest_of(&body),
241 task: fact("task_id"),
242 issue,
243 milestone,
244 branch: fact("branch").filter(|b| b != "DETACHED"),
245 head: fact("head"),
246 created_at: fact("created_at"),
247 name: path
248 .file_name()
249 .and_then(|n| n.to_str())
250 .map(str::to_string),
251 body,
252 })
253}
254
255/// The git common directory of `root`, spelled as the shell resolver spells the local
256/// repository id: absolute as git prints it, or joined to the root when relative.
257fn repository_id(root: &Path) -> Result<String, String> {
258 let out = std::process::Command::new("git")
259 .arg("-C")
260 .arg(root)
261 .args(["rev-parse", "--git-common-dir"])
262 .output()
263 .map_err(|e| format!("git: {e}"))?;
264 if !out.status.success() {
265 return Err(format!("{} is not a git work tree", root.display()));
266 }
267 let dir = String::from_utf8_lossy(&out.stdout).trim().to_string();
268 Ok(if dir.starts_with('/') {
269 dir
270 } else {
271 format!("{}/{dir}", root.display())
272 })
273}
274
275/// Write a consumed handover into `root`'s handovers directory, or return the record that
276/// already holds it. The record's worktree is `mesh:<origin runtime>`: it resolves as
277/// another checkout's handover on the same branch.
278///
279/// This is the half that turns a message into continuity: after it, `majordomus handover
280/// --resolve` on this checkout finds the other machine's handover through the ordinary
281/// resolution and nothing downstream has to know the mesh exists. It is written to be
282/// repeatable — consuming the same handover twice yields the one record — and defensively,
283/// because every value in it was written by another runtime: the front matter and the file
284/// name admit only what their parts are, so neither a key nor a path can be smuggled in
285/// through a branch name or a timestamp.
286///
287/// ```
288/// use majordomus_cli::mesh::handover::{directory, materialize, to_body};
289/// use majordomus_cli::mesh::journal::StreamId;
290/// use majordomus_cli::mesh::state::HandoverView;
291///
292/// let tmp = tempfile::tempdir().unwrap();
293/// let root = tmp.path().join("checkout");
294/// std::fs::create_dir_all(&root).unwrap();
295/// assert!(std::process::Command::new("git")
296/// .arg("-C").arg(&root).args(["init", "-q"]).status().unwrap().success());
297///
298/// let src = tmp.path().join("20260915T100000Z--feature-x--abcdef1--0011.md");
299/// std::fs::write(&src, "---\nbranch: feature/x\nhead: abcdef1234\n---\n\n# Objective\nship\n")
300/// .unwrap();
301/// let stream = StreamId::parse(&format!("{}-{}-{}", "a".repeat(32), "b".repeat(16), "c".repeat(16)))
302/// .unwrap();
303/// let view = HandoverView {
304/// id: to_body(&src, None, None).unwrap().id,
305/// stream,
306/// runtime: format!("{}-{}", "a".repeat(32), "b".repeat(16)),
307/// published_lamport: 1,
308/// handover: to_body(&src, None, None).unwrap(),
309/// consumed_by: vec![],
310/// };
311///
312/// let written = materialize(&root, &view).unwrap();
313/// assert_eq!(written.parent(), Some(directory(&root).as_path()));
314/// assert_eq!(materialize(&root, &view).unwrap(), written, "one handover, one record");
315/// assert!(std::fs::read_to_string(&written).unwrap().contains("worktree: mesh:"));
316/// ```
317pub fn materialize(root: &Path, view: &HandoverView) -> Result<PathBuf, String> {
318 write_record(
319 root,
320 &view.handover,
321 &Provenance {
322 transport: "mesh",
323 marker_key: "mesh_handover",
324 marker: view.id.clone(),
325 origin_key: "mesh_origin",
326 origin: view.runtime.clone(),
327 working_tree: None,
328 changed_files: Vec::new(),
329 },
330 )
331}
332
333/// Where a handover written into this checkout came from, and what its front matter says
334/// about the source state it was written against. The mesh and a continuity record are the
335/// two transports that bring one; both write through [`write_record`], so a handover that
336/// arrived from another machine has one shape whichever way it travelled.
337#[derive(Debug, Clone)]
338pub(crate) struct Provenance {
339 /// `mesh` or `continuity`: the file-name segment and the prefix of owner and worktree.
340 pub transport: &'static str,
341 /// The front-matter key whose value identifies the handover on its transport; a record
342 /// holding `<marker_key>: <marker>` already is this handover, which is what makes
343 /// writing it twice write one file.
344 pub marker_key: &'static str,
345 /// That value (32 hex).
346 pub marker: String,
347 /// The front-matter key naming where it came from.
348 pub origin_key: &'static str,
349 /// Where it came from: a runtime id or a device node id (hex).
350 pub origin: String,
351 /// `clean` or `dirty` at the origin when the transport carries it. `None` writes
352 /// `clean`, as the mesh, which does not carry it, always has.
353 pub working_tree: Option<&'static str>,
354 /// The repository-relative paths that differed from HEAD at the origin.
355 pub changed_files: Vec<String>,
356}
357
358/// Write a handover that arrived from elsewhere into `root`'s handovers directory, or
359/// return the record that already holds it. See [`materialize`] for why every value is
360/// written defensively.
361pub(crate) fn write_record(
362 root: &Path,
363 h: &HandoverBody,
364 from: &Provenance,
365) -> Result<PathBuf, String> {
366 let dir = directory(root);
367 std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
368 let hexish = |v: &str| {
369 v.chars()
370 .filter(char::is_ascii_hexdigit)
371 .take(64)
372 .collect::<String>()
373 };
374 let marker_value = hexish(&from.marker);
375 let origin = hexish(&from.origin.replace('-', ""));
376 if marker_value.len() < 16 {
377 return Err("a handover from elsewhere is identified by at least 16 hex".into());
378 }
379 let marker = format!("{}: {marker_value}", from.marker_key);
380 if let Ok(entries) = std::fs::read_dir(&dir) {
381 for entry in entries.flatten() {
382 let path = entry.path();
383 if path.extension().is_some_and(|x| x == "md")
384 && std::fs::read_to_string(&path).is_ok_and(|t| t.lines().any(|l| l == marker))
385 {
386 return Ok(path);
387 }
388 }
389 }
390 // Everything below comes from another runtime. Validation refused multi-line values at
391 // ingest; this writes defensively anyway, because a file name and a front matter built
392 // from a peer's words are exactly where a path or a key would be smuggled.
393 let origin_label = if from.transport == "mesh" {
394 // the mesh's runtime id keeps its dash, as it always has
395 one_line(&from.origin)
396 .chars()
397 .filter(|c| c.is_ascii_hexdigit() || *c == '-')
398 .collect()
399 } else {
400 origin
401 };
402 let branch = one_line(h.branch.as_deref().unwrap_or("DETACHED"));
403 let head = one_line(h.head.as_deref().unwrap_or("NONE"));
404 let created = one_line(
405 &h.created_at
406 .clone()
407 .unwrap_or_else(|| crate::peers::rfc3339(std::time::SystemTime::now())),
408 );
409 let mut text = String::new();
410 text.push_str("---\nschema_version: 1\n");
411 text.push_str(&format!("created_at: {created}\n"));
412 text.push_str(&format!(
413 "task_id: {}\n",
414 one_line(h.task.as_deref().unwrap_or("none"))
415 ));
416 text.push_str("profile: none\n");
417 let transport = from.transport;
418 text.push_str(&format!("owner: \"{transport}:{origin_label}\"\n"));
419 text.push_str(&format!("repository_id: {}\n", repository_id(root)?));
420 text.push_str(&format!("worktree: {transport}:{origin_label}\n"));
421 text.push_str(&format!(
422 "branch: {branch}\nhead: {head}\nworking_tree: {}\nchanged_files:\n",
423 from.working_tree.unwrap_or("clean")
424 ));
425 for path in &from.changed_files {
426 let path = one_line(path);
427 if !path.is_empty() && !path.starts_with('/') && !path.split('/').any(|c| c == "..") {
428 text.push_str(&format!(" - {path}\n"));
429 }
430 }
431 text.push_str(&format!("{marker}\n{}: {origin_label}\n", from.origin_key));
432 if let Some(issue) = &h.issue {
433 text.push_str(&format!("issue: \"{}\"\n", one_line(issue)));
434 }
435 if let Some(milestone) = &h.milestone {
436 text.push_str(&format!("milestone: \"{}\"\n", one_line(milestone)));
437 }
438 text.push_str("---\n\n");
439 text.push_str(&h.body);
440 // The file name admits only what its parts are: digits and T/Z of a timestamp, hex of a
441 // commit, a branch key of [A-Za-z0-9_-] (no dots, so never `..`), hex of the id.
442 let mut compact: String = created
443 .chars()
444 .filter(|c| c.is_ascii_digit() || *c == 'T' || *c == 'Z')
445 .take(16)
446 .collect();
447 if compact.is_empty() {
448 compact = "00000000T000000Z".into();
449 }
450 let branch_key: String = branch
451 .chars()
452 .map(|c| {
453 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
454 c
455 } else {
456 '-'
457 }
458 })
459 .take(64)
460 .collect();
461 let mut head7: String = head
462 .chars()
463 .filter(char::is_ascii_hexdigit)
464 .take(7)
465 .collect();
466 if head7.is_empty() {
467 head7 = "0000000".into();
468 }
469 let name = format!(
470 "{compact}--{transport}--{branch_key}--{head7}--{}.md",
471 &marker_value[..16]
472 );
473 let path = dir.join(&name);
474 if name.contains('/') || path.parent() != Some(dir.as_path()) {
475 return Err(format!(
476 "refusing to write a handover outside {}",
477 dir.display()
478 ));
479 }
480 let tmp = dir.join(format!(".tmp.{transport}.{}", &marker_value[..16]));
481 std::fs::write(&tmp, text).map_err(|e| format!("{}: {e}", tmp.display()))?;
482 #[cfg(unix)]
483 {
484 use std::os::unix::fs::PermissionsExt;
485 let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
486 }
487 std::fs::rename(&tmp, &path).map_err(|e| format!("{}: {e}", path.display()))?;
488 Ok(path)
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494 use crate::mesh::journal::StreamId;
495
496 const RECORD: &str = "---\nschema_version: 1\ncreated_at: 2026-09-15T10:00:00Z\ntask_id: t-1\nprofile: implementation\nowner: \"k\"\nrepository_id: /somewhere/.git\nworktree: /somewhere\nbranch: feature/x\nhead: abcdef1234\nworking_tree: dirty\nchanged_files:\n - apps/x.rs\n---\n\n# Objective\nship\n# Current State\nhalf\n# Next Action\nrest\n";
497
498 #[test]
499 fn two_records_of_one_second_resolve_to_the_later_write_not_the_larger_suffix() {
500 let root = tempfile::tempdir().unwrap();
501 let dir = directory(root.path());
502 std::fs::create_dir_all(&dir).unwrap();
503 let earlier = dir.join("20261004T010000Z--b--0000000--ffff.md");
504 let later = dir.join("20261004T010000Z--b--0000000--0000.md");
505 std::fs::write(&earlier, "---\n---\nfirst").unwrap();
506 std::fs::write(&later, "---\n---\nsecond").unwrap();
507 let at = std::time::SystemTime::now();
508 let set = |p: &Path, t: std::time::SystemTime| {
509 std::fs::File::options()
510 .write(true)
511 .open(p)
512 .unwrap()
513 .set_modified(t)
514 .unwrap();
515 };
516 set(&earlier, at - std::time::Duration::from_millis(500));
517 set(&later, at);
518 assert_eq!(
519 latest(root.path()).unwrap(),
520 later,
521 "the suffix orders nothing"
522 );
523 // and a record of a later second still wins whatever its write time
524 let next = dir.join("20261004T010001Z--b--0000000--0000.md");
525 std::fs::write(&next, "---\n---\nthird").unwrap();
526 set(&next, at - std::time::Duration::from_secs(60));
527 assert_eq!(latest(root.path()).unwrap(), next);
528 }
529
530 #[test]
531 fn a_hostile_handover_is_written_inside_the_directory_with_no_injected_keys() {
532 let dir = tempfile::tempdir().unwrap();
533 let root = dir.path().join("checkout");
534 std::fs::create_dir_all(&root).unwrap();
535 assert!(std::process::Command::new("git")
536 .arg("-C")
537 .arg(&root)
538 .args(["init", "-q"])
539 .status()
540 .unwrap()
541 .success());
542 let body = "# Objective\nx\n".to_string();
543 let view = HandoverView {
544 id: HandoverBody::digest_of(&body),
545 stream: StreamId::parse(&format!(
546 "{}-{}-{}",
547 "a".repeat(32),
548 "b".repeat(16),
549 "c".repeat(16)
550 ))
551 .unwrap(),
552 runtime: format!("{}-{}", "a".repeat(32), "b".repeat(16)),
553 published_lamport: 1,
554 handover: HandoverBody {
555 id: HandoverBody::digest_of(&body),
556 task: Some("t\nrepository_id: evil".into()),
557 issue: Some("\"#1\"\nworktree: evil".into()),
558 milestone: None,
559 branch: Some("../../../../.claude/commands".into()),
560 head: Some("../x\n---".into()),
561 created_at: Some("../../../../.claude/commands/pwn\nowner: evil".into()),
562 name: None,
563 body,
564 },
565 consumed_by: vec![],
566 };
567 let path = materialize(&root, &view).unwrap();
568 assert_eq!(path.parent(), Some(directory(&root).as_path()));
569 let text = std::fs::read_to_string(&path).unwrap();
570 let front = text.split("\n---\n").next().unwrap();
571 assert!(!front.contains("\nrepository_id: evil"));
572 assert!(!front.contains("\nworktree: evil"));
573 assert!(!front.contains("\nowner: evil"));
574 assert!(!root.join(".claude").exists());
575 // And validation refuses the same handover before it is ever stored.
576 assert!(crate::mesh::journal::EventBody::HandoverPublished {
577 handover: view.handover.clone()
578 }
579 .validate()
580 .is_err());
581 }
582
583 #[test]
584 fn a_record_reads_into_a_bounded_body_without_its_paths() {
585 let dir = tempfile::tempdir().unwrap();
586 let path = dir
587 .path()
588 .join("20260915T100000Z--feature-x--abcdef1--0011.md");
589 std::fs::write(&path, RECORD).unwrap();
590 let body = to_body(&path, Some("#184".into()), None).unwrap();
591 assert_eq!(body.task.as_deref(), Some("t-1"));
592 assert_eq!(body.branch.as_deref(), Some("feature/x"));
593 assert_eq!(body.issue.as_deref(), Some("#184"));
594 assert!(body.body.starts_with("# Objective"));
595 assert_eq!(body.id, HandoverBody::digest_of(&body.body));
596 let json = serde_json::to_string(&body).unwrap();
597 assert!(
598 !json.contains("/somewhere"),
599 "no path of the author's disk travels"
600 );
601 }
602
603 #[test]
604 fn materializing_twice_writes_one_record_the_resolver_can_read() {
605 let dir = tempfile::tempdir().unwrap();
606 let root = dir.path().join("checkout");
607 std::fs::create_dir_all(&root).unwrap();
608 assert!(std::process::Command::new("git")
609 .arg("-C")
610 .arg(&root)
611 .args(["init", "-q"])
612 .status()
613 .unwrap()
614 .success());
615 let src = dir
616 .path()
617 .join("20260915T100000Z--feature-x--abcdef1--0011.md");
618 std::fs::write(&src, RECORD).unwrap();
619 let body = to_body(&src, None, None).unwrap();
620 let view = HandoverView {
621 id: body.id.clone(),
622 stream: StreamId::parse(&format!(
623 "{}-{}-{}",
624 "a".repeat(32),
625 "b".repeat(16),
626 "c".repeat(16)
627 ))
628 .unwrap(),
629 runtime: format!("{}-{}", "a".repeat(32), "b".repeat(16)),
630 published_lamport: 1,
631 handover: body,
632 consumed_by: vec![],
633 };
634 let first = materialize(&root, &view).unwrap();
635 let second = materialize(&root, &view).unwrap();
636 assert_eq!(first, second, "one handover, one record");
637 let (front, text) = read(&first).unwrap();
638 assert_eq!(front["schema_version"], "1");
639 assert_eq!(front["branch"], "feature/x");
640 assert_eq!(front["head"], "abcdef1234");
641 assert!(front["worktree"].starts_with("mesh:"));
642 assert!(front["repository_id"].ends_with(".git"));
643 assert!(text.contains("# Next Action"));
644 assert_eq!(latest(&root).unwrap(), first);
645 }
646
647 /// A handover that arrived from elsewhere is written with what its transport says about
648 /// the source it was written against — and with nothing of it that names a place outside
649 /// the repository.
650 #[test]
651 fn a_record_from_elsewhere_carries_its_source_state_and_only_repository_paths() {
652 let w = crate::continuity::tests_support::World::new();
653 let root = w.root("a");
654 let body = "# Objective\nship\n\n# Next Action\nrest\n".to_string();
655 let h = HandoverBody {
656 id: HandoverBody::digest_of(&body),
657 task: None,
658 issue: None,
659 milestone: None,
660 branch: Some("feature/x".into()),
661 head: Some("abcdef1234".into()),
662 created_at: Some("2026-10-03T12:00:00Z".into()),
663 name: None,
664 body,
665 };
666 let from = |marker: &str| Provenance {
667 transport: "continuity",
668 marker_key: "continuity_record",
669 marker: marker.into(),
670 origin_key: "continuity_device",
671 origin: "b".repeat(32),
672 working_tree: Some("dirty"),
673 changed_files: vec![
674 "lib/a.rs".into(),
675 "/etc/passwd".into(),
676 "../outside".into(),
677 String::new(),
678 ],
679 };
680 let err = write_record(&root, &h, &from("abc")).unwrap_err();
681 assert!(err.contains("at least 16 hex"), "{err}");
682
683 let written = write_record(&root, &h, &from(&"a".repeat(32))).unwrap();
684 let (front, text) = read(&written).unwrap();
685 assert_eq!(front.get("working_tree").map(String::as_str), Some("dirty"));
686 assert!(text.starts_with("# Objective"));
687 let whole = std::fs::read_to_string(&written).unwrap();
688 assert!(
689 whole.contains("changed_files:\n - lib/a.rs\ncontinuity_record: "),
690 "{whole}"
691 );
692 // writing it twice writes one file
693 assert_eq!(
694 write_record(&root, &h, &from(&"a".repeat(32))).unwrap(),
695 written
696 );
697 }
698}