1use std::fs::{self, File};
16use std::io::{self, BufRead, BufReader, Write as _};
17use std::path::{Path, PathBuf};
18use std::sync::{Arc, Mutex, RwLock as StdRwLock};
19
20use serde::{Deserialize, Serialize};
21use zeph_common::anchor::{Anchor, AnchorStore, AnchorSubsystem};
22use zeph_common::hash_chain::{
23 ChainHash, ChainKeyRing, KeyResolution, chain_next, genesis,
24 verify_chained_prefix_with_checkpoint,
25};
26use zeph_llm::provider::{Message, MessagePart};
27
28use super::error::SubAgentError;
29use super::state::SubAgentState;
30
31pub const CHAIN_DOMAIN: &str = "zeph-subagent transcript v1";
35
36static HISTORY_INTEGRITY: StdRwLock<Option<Arc<ChainKeyRing>>> = StdRwLock::new(None);
52
53pub fn configure_history_integrity(ring: Option<Arc<ChainKeyRing>>) {
77 if let Ok(mut guard) = HISTORY_INTEGRITY.write() {
78 *guard = ring;
79 }
80}
81
82fn history_integrity() -> Option<Arc<ChainKeyRing>> {
83 HISTORY_INTEGRITY.read().ok().and_then(|g| g.clone())
84}
85
86static ANCHOR_STORE: StdRwLock<Option<Arc<dyn AnchorStore>>> = StdRwLock::new(None);
91
92pub fn configure_anchor_store(store: Option<Arc<dyn AnchorStore>>) {
96 if let Ok(mut guard) = ANCHOR_STORE.write() {
97 *guard = store;
98 }
99}
100
101fn anchor_store() -> Option<Arc<dyn AnchorStore>> {
102 ANCHOR_STORE.read().ok().and_then(|g| g.clone())
103}
104
105fn file_identity(path: &Path) -> Vec<u8> {
109 path.file_stem()
110 .map(|s| s.to_string_lossy().into_owned())
111 .unwrap_or_default()
112 .into_bytes()
113}
114
115static WARNED_LEGACY_UNDER_KEY: std::sync::LazyLock<StdRwLock<std::collections::HashSet<PathBuf>>> =
119 std::sync::LazyLock::new(|| StdRwLock::new(std::collections::HashSet::new()));
120
121fn warn_legacy_under_active_key_once(path: &Path) {
131 let already_warned = WARNED_LEGACY_UNDER_KEY
132 .read()
133 .is_ok_and(|set| set.contains(path));
134 if already_warned {
135 return;
136 }
137 if let Ok(mut set) = WARNED_LEGACY_UNDER_KEY.write()
138 && !set.insert(path.to_path_buf())
139 {
140 return; }
142 tracing::warn!(
143 path = %path.display(),
144 "history-chain integrity: transcript classifies as legacy (no chain field anywhere) \
145 while a history-integrity key IS configured for this process — this is expected for \
146 genuine pre-upgrade content, but is also the signature of a full chain-strip downgrade \
147 attack (issue #6449, the vault-anchor gap); accepted per FR-006, flagged for operator \
148 visibility"
149 );
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct TranscriptEntry {
159 pub seq: u32,
161 pub timestamp: String,
163 pub message: Message,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub chain: Option<String>,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct TranscriptMeta {
181 pub agent_id: String,
183 pub agent_name: String,
185 pub def_name: String,
187 pub status: SubAgentState,
189 pub started_at: String,
191 #[serde(skip_serializing_if = "Option::is_none")]
193 pub finished_at: Option<String>,
194 #[serde(skip_serializing_if = "Option::is_none")]
196 pub resumed_from: Option<String>,
197 pub turns_used: u32,
199 #[serde(default)]
204 pub mcp_tool_names: Vec<String>,
205}
206
207struct TranscriptWriteState {
224 file: File,
225 prev: Option<ChainHash>,
229 count: u64,
233}
234
235#[derive(Clone)]
236pub struct TranscriptWriter {
237 state: Arc<Mutex<TranscriptWriteState>>,
243 file_identity: Vec<u8>,
244 ring: Option<Arc<ChainKeyRing>>,
248}
249
250impl TranscriptWriter {
251 pub fn new(path: &Path) -> io::Result<Self> {
266 if let Some(parent) = path.parent() {
267 fs::create_dir_all(parent)?;
268 }
269 let ring = history_integrity();
270 let identity = file_identity(path);
271
272 let (prev, count) = if path.exists() {
273 let entries =
274 parse_entries(path, false).map_err(|e| io::Error::other(e.to_string()))?;
275 let count = u64::try_from(entries.len()).unwrap_or(u64::MAX);
276 let anchor = match anchor_store() {
277 Some(store) => store
278 .get_sync(AnchorSubsystem::SubagentTranscript, &identity)
279 .map_err(|e| io::Error::other(format!("anchor lookup failed: {e}")))?,
280 None => None,
281 };
282 let (_messages, head) =
283 verify_and_extract_messages(path, entries, ring.as_deref(), anchor.as_ref())
284 .map_err(|e| io::Error::other(e.to_string()))?;
285 (head, count)
286 } else {
287 (None, 0)
288 };
289
290 let file = zeph_common::fs_secure::append_private(path)?;
291 Ok(Self {
292 state: Arc::new(Mutex::new(TranscriptWriteState { file, prev, count })),
293 file_identity: identity,
294 ring,
295 })
296 }
297
298 pub async fn append(&self, seq: u32, message: &Message) -> io::Result<()> {
318 let mut persisted_message = message.clone();
319 persisted_message.parts = MessagePart::strip_images(&persisted_message.parts);
320 let timestamp = utc_now();
321 let state = Arc::clone(&self.state);
322 let ring = self.ring.clone();
323 let identity = self.file_identity.clone();
324
325 tokio::task::spawn_blocking(move || {
326 let mut guard = state
327 .lock()
328 .map_err(|_| io::Error::other("transcript writer lock poisoned"))?;
329
330 let mut entry = TranscriptEntry {
331 seq,
332 timestamp,
333 message: persisted_message,
334 chain: None,
335 };
336
337 let new_head = match ring.as_deref() {
338 Some(ring) => {
339 let content = serde_json::to_vec(&entry)
340 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
341 let base = guard.prev.unwrap_or_else(|| {
342 genesis(
343 &ring.current_key(),
344 CHAIN_DOMAIN,
345 &identity,
346 ring.current_epoch(),
347 )
348 });
349 let h = chain_next(&ring.current_key(), &base, &content);
350 entry.chain = Some(h.to_hex());
351 Some(h)
352 }
353 None => None,
354 };
355
356 let line = serde_json::to_string(&entry)
357 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
358 guard.file.write_all(line.as_bytes())?;
359 guard.file.write_all(b"\n")?;
360 guard.file.flush()?;
361
362 if let Some(h) = new_head {
365 guard.prev = Some(h);
366 }
367 guard.count += 1;
368 Ok(())
369 })
370 .await
371 .map_err(|e| io::Error::other(format!("spawn_blocking panicked: {e}")))?
372 }
373
374 pub async fn finalize(self) -> io::Result<()> {
389 let Some(store) = anchor_store() else {
390 return Ok(());
391 };
392 let (head, count) = {
393 let guard = self
394 .state
395 .lock()
396 .map_err(|_| io::Error::other("transcript writer lock poisoned"))?;
397 let Some(head) = guard.prev else {
398 return Ok(());
399 };
400 (head, guard.count)
401 };
402 let epoch = self.ring.as_ref().map_or(0, |r| r.current_epoch());
403 let anchor = Anchor::new(epoch, count, head);
404 store
405 .put(
406 AnchorSubsystem::SubagentTranscript,
407 &self.file_identity,
408 anchor,
409 )
410 .await
411 .map_err(|e| io::Error::other(format!("anchor put failed: {e}")))
412 }
413
414 pub fn write_meta(dir: &Path, agent_id: &str, meta: &TranscriptMeta) -> io::Result<()> {
420 let path = dir.join(format!("{agent_id}.meta.json"));
421 let content = serde_json::to_string_pretty(meta)
422 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
423 zeph_common::fs_secure::write_private(&path, content.as_bytes())
424 }
425
426 pub async fn write_meta_async(
433 dir: &Path,
434 agent_id: &str,
435 meta: &TranscriptMeta,
436 ) -> io::Result<()> {
437 let path = dir.join(format!("{agent_id}.meta.json"));
438 let content = serde_json::to_string_pretty(meta)
439 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
440 let bytes = content.into_bytes();
441 tokio::task::spawn_blocking(move || zeph_common::fs_secure::write_private(&path, &bytes))
442 .await
443 .map_err(|e| io::Error::other(format!("spawn_blocking panicked: {e}")))?
444 }
445}
446
447pub struct TranscriptReader;
454
455impl TranscriptReader {
456 pub fn load(path: &Path) -> Result<Vec<Message>, SubAgentError> {
469 Self::load_impl(path, false)
470 }
471
472 pub fn load_strict(path: &Path) -> Result<Vec<Message>, SubAgentError> {
487 Self::load_impl(path, true)
488 }
489
490 fn load_impl(path: &Path, strict: bool) -> Result<Vec<Message>, SubAgentError> {
491 if !path.exists() {
492 let meta_path = if let (Some(parent), Some(stem)) = (path.parent(), path.file_stem()) {
496 parent.join(format!("{}.meta.json", stem.to_string_lossy()))
497 } else {
498 path.with_extension("meta.json")
499 };
500 if meta_path.exists() {
501 return Err(SubAgentError::Transcript(format!(
502 "transcript file '{}' is missing but meta sidecar exists — \
503 transcript data may have been deleted",
504 path.display()
505 )));
506 }
507 return Ok(vec![]);
508 }
509
510 let entries = parse_entries(path, strict)?;
511 let ring = history_integrity();
512 let identity = file_identity(path);
513 let anchor = match anchor_store() {
514 Some(store) => store
515 .get_sync(AnchorSubsystem::SubagentTranscript, &identity)
516 .map_err(|e| SubAgentError::Integrity(format!("anchor lookup failed: {e}")))?,
517 None => None,
518 };
519 let (messages, _head) =
520 verify_and_extract_messages(path, entries, ring.as_deref(), anchor.as_ref())?;
521 Ok(messages)
522 }
523
524 pub fn load_meta(dir: &Path, agent_id: &str) -> Result<TranscriptMeta, SubAgentError> {
531 let path = dir.join(format!("{agent_id}.meta.json"));
532 let content = fs::read_to_string(&path).map_err(|e| {
533 if e.kind() == io::ErrorKind::NotFound {
534 SubAgentError::NotFound(agent_id.to_owned())
535 } else {
536 SubAgentError::Transcript(format!("failed to read meta '{}': {e}", path.display()))
537 }
538 })?;
539 serde_json::from_str(&content).map_err(|e| {
540 SubAgentError::Transcript(format!("failed to parse meta '{}': {e}", path.display()))
541 })
542 }
543
544 pub fn find_by_prefix(dir: &Path, prefix: &str) -> Result<String, SubAgentError> {
553 let entries = fs::read_dir(dir).map_err(|e| {
554 SubAgentError::Transcript(format!(
555 "failed to read transcript dir '{}': {e}",
556 dir.display()
557 ))
558 })?;
559
560 let mut matches: Vec<String> = Vec::new();
561 for entry in entries {
562 let entry = entry
563 .map_err(|e| SubAgentError::Transcript(format!("failed to read dir entry: {e}")))?;
564 let name = entry.file_name();
565 let name_str = name.to_string_lossy();
566 if let Some(agent_id) = name_str.strip_suffix(".meta.json")
567 && agent_id.starts_with(prefix)
568 {
569 matches.push(agent_id.to_owned());
570 }
571 }
572
573 match matches.len() {
574 0 => Err(SubAgentError::NotFound(prefix.to_owned())),
575 1 => Ok(matches.remove(0)),
576 n => Err(SubAgentError::AmbiguousId(prefix.to_owned(), n)),
577 }
578 }
579}
580
581fn parse_entries(path: &Path, strict: bool) -> Result<Vec<TranscriptEntry>, SubAgentError> {
596 let file = File::open(path).map_err(|e| {
597 SubAgentError::Transcript(format!(
598 "failed to open transcript '{}': {e}",
599 path.display()
600 ))
601 })?;
602 let reader = BufReader::new(file);
603 let mut entries = Vec::new();
604 for (line_no, line_result) in reader.lines().enumerate() {
605 let line = match line_result {
606 Ok(l) => l,
607 Err(e) => {
608 if strict {
609 return Err(SubAgentError::Transcript(format!(
610 "failed to read transcript '{}' line {}: {e}",
611 path.display(),
612 line_no + 1
613 )));
614 }
615 tracing::warn!(
616 path = %path.display(),
617 line = line_no + 1,
618 error = %e,
619 "failed to read transcript line — skipping"
620 );
621 continue;
622 }
623 };
624 let trimmed = line.trim();
625 if trimmed.is_empty() {
626 continue;
627 }
628 match serde_json::from_str::<TranscriptEntry>(trimmed) {
629 Ok(entry) => entries.push(entry),
630 Err(e) => {
631 if strict {
632 return Err(SubAgentError::Transcript(format!(
633 "malformed transcript entry in '{}' line {}: {e}",
634 path.display(),
635 line_no + 1
636 )));
637 }
638 tracing::warn!(
639 path = %path.display(),
640 line = line_no + 1,
641 error = %e,
642 "malformed transcript entry — skipping"
643 );
644 }
645 }
646 }
647 Ok(entries)
648}
649
650#[allow(clippy::too_many_lines)]
674fn verify_and_extract_messages(
675 path: &Path,
676 entries: Vec<TranscriptEntry>,
677 ring: Option<&ChainKeyRing>,
678 anchor: Option<&Anchor>,
679) -> Result<(Vec<Message>, Option<ChainHash>), SubAgentError> {
680 let Some(chain_start) = entries.iter().position(|e| e.chain.is_some()) else {
681 if let Some(anchor) = anchor {
688 tracing::error!(
689 audit_event = "history_integrity_tamper",
690 subsystem = "subagent_transcript",
691 reason = "whole_strip_legacy_with_anchor",
692 path = %path.display(),
693 anchored_count = anchor.count,
694 "TAMPER DETECTED: transcript is legacy-looking but a vault anchor exists for it \
695 (issue #6449)"
696 );
697 return Err(SubAgentError::Integrity(format!(
698 "TAMPER DETECTED in transcript '{}': file has no chain metadata (legacy-looking) \
699 but a vault anchor exists for it (anchored at count={}) — this file was \
700 previously chained and its chain fields have been stripped",
701 path.display(),
702 anchor.count
703 )));
704 }
705 if ring.is_some() {
712 warn_legacy_under_active_key_once(path);
713 }
714 return Ok((entries.into_iter().map(|e| e.message).collect(), None));
715 };
716
717 for (offset, entry) in entries[chain_start..].iter().enumerate() {
718 if entry.chain.is_none() {
719 return Err(SubAgentError::Integrity(format!(
720 "transcript '{}' entry at chained-region position {offset} is missing its \
721 chain field while earlier entries in this file are chained — partial strip \
722 detected, TAMPER DETECTED",
723 path.display()
724 )));
725 }
726 }
727
728 let Some(ring) = ring else {
729 return Err(SubAgentError::Integrity(format!(
730 "transcript '{}' carries chain metadata but no history-integrity key is configured \
731 for this process — refusing to trust it unverified (NFR-004)",
732 path.display()
733 )));
734 };
735
736 let mut chained: Vec<(Vec<u8>, ChainHash)> = Vec::with_capacity(entries.len() - chain_start);
737 for entry in &entries[chain_start..] {
738 let stored_hex = entry.chain.as_deref().unwrap_or_default();
739 let stored = ChainHash::from_hex(stored_hex).map_err(|_| {
740 SubAgentError::Integrity(format!(
741 "transcript '{}' has a malformed chain hash",
742 path.display()
743 ))
744 })?;
745 let mut stripped = entry.clone();
746 stripped.chain = None;
747 let content = serde_json::to_vec(&stripped).map_err(|e| {
748 SubAgentError::Transcript(format!("failed to canonicalize transcript entry: {e}"))
749 })?;
750 chained.push((content, stored));
751 }
752
753 let identity = file_identity(path);
754 let on_disk_count = u64::try_from(entries.len()).unwrap_or(u64::MAX);
755 let checkpoint_index = anchor.and_then(|a| {
759 a.count
760 .checked_sub(u64::try_from(chain_start).unwrap_or(u64::MAX) + 1)
761 });
762 let (head, checkpoint_head, resolution) = verify_chained_prefix_with_checkpoint(
763 ring,
764 CHAIN_DOMAIN,
765 &identity,
766 &chained,
767 checkpoint_index.unwrap_or(u64::MAX),
768 )
769 .map_err(|e| describe_chain_error(path, &e))?;
770
771 if let KeyResolution::Rekeyed(epoch) = resolution {
772 tracing::info!(
773 path = %path.display(),
774 epoch,
775 "transcript verified under a previous key epoch (re-keyed, not tampered)"
776 );
777 }
778
779 if let Some(anchor) = anchor {
780 if on_disk_count < anchor.count {
781 tracing::error!(
782 audit_event = "history_integrity_tamper",
783 subsystem = "subagent_transcript",
784 reason = "truncated_below_anchor_count",
785 path = %path.display(),
786 on_disk_count,
787 anchored_count = anchor.count,
788 "TAMPER DETECTED: transcript truncated below its anchored count (issue #6449)"
789 );
790 return Err(SubAgentError::Integrity(format!(
791 "TAMPER DETECTED in transcript '{}': on-disk entry count ({on_disk_count}) is \
792 below the anchored count ({}) — the file was truncated after being anchored",
793 path.display(),
794 anchor.count
795 )));
796 }
797 let anchor_head = anchor.head().map_err(|e| {
798 SubAgentError::Integrity(format!(
799 "transcript '{}' anchor is malformed: {e}",
800 path.display()
801 ))
802 })?;
803 match checkpoint_head {
804 Some(h) if h == anchor_head => {}
805 _ => {
806 tracing::error!(
807 audit_event = "history_integrity_tamper",
808 subsystem = "subagent_transcript",
809 reason = "anchor_head_mismatch",
810 path = %path.display(),
811 anchored_count = anchor.count,
812 "TAMPER DETECTED: transcript chain head at the anchored count does not match \
813 the stored vault anchor (issue #6449)"
814 );
815 return Err(SubAgentError::Integrity(format!(
816 "TAMPER DETECTED in transcript '{}': chain head at the anchored count ({}) \
817 does not match the stored vault anchor",
818 path.display(),
819 anchor.count
820 )));
821 }
822 }
823 }
824
825 let messages = entries.into_iter().map(|e| e.message).collect();
826 Ok((messages, Some(head)))
827}
828
829fn describe_chain_error(path: &Path, err: &zeph_common::hash_chain::ChainError) -> SubAgentError {
833 use zeph_common::hash_chain::ChainError;
834 match err {
835 ChainError::Unverifiable => SubAgentError::Integrity(format!(
836 "transcript '{}' is unverifiable: no known key epoch (current or previous rotation \
837 window) produces a valid chain — possibly re-keyed past the rotation window, or \
838 tampered; this is fail-closed by design (NFR-004) and cannot be auto-recovered",
839 path.display()
840 )),
841 ChainError::Mismatch { index } => SubAgentError::Integrity(format!(
842 "TAMPER DETECTED in transcript '{}': chain hash mismatch at chained-entry index \
843 {index} — content was modified, reordered, or deleted after being written",
844 path.display()
845 )),
846 other => SubAgentError::Integrity(format!(
847 "transcript '{}' failed chain verification: {other}",
848 path.display()
849 )),
850 }
851}
852
853pub fn sweep_old_transcripts(dir: &Path, max_files: usize) -> io::Result<usize> {
877 if max_files == 0 {
878 return Ok(0);
879 }
880
881 if !dir.exists() {
883 fs::create_dir_all(dir)?;
884 return Ok(0);
885 }
886
887 let mut jsonl_files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
888 for entry in fs::read_dir(dir)? {
889 let entry = entry?;
890 let path = entry.path();
891 if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
892 let mtime = entry
893 .metadata()
894 .and_then(|m| m.modified())
895 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
896 jsonl_files.push((path, mtime));
897 }
898 }
899
900 if jsonl_files.len() <= max_files {
901 return Ok(0);
902 }
903
904 jsonl_files.sort_by_key(|(_, mtime)| *mtime);
906
907 let to_delete = jsonl_files.len() - max_files;
908 let mut deleted = 0;
909 for (path, _) in jsonl_files.into_iter().take(to_delete) {
910 let meta = path.with_extension("meta.json");
912 if meta.exists() {
913 let _ = fs::remove_file(&meta);
914 }
915 fs::remove_file(&path)?;
916 deleted += 1;
917 }
918 Ok(deleted)
919}
920
921#[must_use]
923pub(crate) fn utc_now() -> String {
924 let secs = std::time::SystemTime::now()
927 .duration_since(std::time::UNIX_EPOCH)
928 .unwrap_or_default()
929 .as_secs();
930 let (y, mo, d, h, mi, s) = epoch_to_parts(secs);
931 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
932}
933
934fn epoch_to_parts(epoch: u64) -> (u32, u32, u32, u32, u32, u32) {
940 let sec = epoch % 60;
941 let epoch = epoch / 60;
942 let min = epoch % 60;
943 let epoch = epoch / 60;
944 let hour = epoch % 24;
945 let days = epoch / 24;
946
947 let z = days + 719_468;
949 let era = z / 146_097;
950 let doe = z - era * 146_097;
951 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
952 let year = yoe + era * 400;
953 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
954 let mp = (5 * doy + 2) / 153;
955 let day = doy - (153 * mp + 2) / 5 + 1;
956 let month = if mp < 10 { mp + 3 } else { mp - 9 };
957 let year = if month <= 2 { year + 1 } else { year };
958
959 #[allow(clippy::cast_possible_truncation)]
961 (
962 year as u32,
963 month as u32,
964 day as u32,
965 hour as u32,
966 min as u32,
967 sec as u32,
968 )
969}
970
971#[cfg(test)]
972mod tests {
973 use std::assert_matches;
974 use zeph_llm::provider::{ImageData, Message, MessageMetadata, MessagePart, Role};
975
976 use super::*;
977
978 fn test_message(role: Role, content: &str) -> Message {
979 Message {
980 role,
981 content: content.to_owned(),
982 parts: vec![],
983 metadata: MessageMetadata::default(),
984 }
985 }
986
987 fn test_meta(agent_id: &str) -> TranscriptMeta {
988 TranscriptMeta {
989 agent_id: agent_id.to_owned(),
990 agent_name: "bot".to_owned(),
991 def_name: "bot".to_owned(),
992 status: SubAgentState::Completed,
993 started_at: "2026-01-01T00:00:00Z".to_owned(),
994 finished_at: Some("2026-01-01T00:01:00Z".to_owned()),
995 resumed_from: None,
996 turns_used: 2,
997 mcp_tool_names: Vec::new(),
998 }
999 }
1000
1001 #[tokio::test]
1002 async fn writer_reader_roundtrip() {
1003 let dir = tempfile::tempdir().unwrap();
1004 let path = dir.path().join("test.jsonl");
1005
1006 let msg1 = test_message(Role::User, "hello");
1007 let msg2 = test_message(Role::Assistant, "world");
1008
1009 let writer = TranscriptWriter::new(&path).unwrap();
1010 writer.append(0, &msg1).await.unwrap();
1011 writer.append(1, &msg2).await.unwrap();
1012 drop(writer);
1013
1014 let messages = TranscriptReader::load(&path).unwrap();
1015 assert_eq!(messages.len(), 2);
1016 assert_eq!(messages[0].content, "hello");
1017 assert_eq!(messages[1].content, "world");
1018 }
1019
1020 #[tokio::test]
1024 async fn append_strips_image_parts() {
1025 let dir = tempfile::tempdir().unwrap();
1026 let path = dir.path().join("test.jsonl");
1027
1028 let mut msg = test_message(Role::User, "look at this");
1029 msg.parts = vec![
1030 MessagePart::Text {
1031 text: "look at this".to_owned(),
1032 },
1033 MessagePart::Image(Box::new(ImageData {
1034 data: vec![0xFFu8, 0xD8, 0xFF, 0xE0],
1035 mime_type: "image/jpeg".to_owned(),
1036 })),
1037 ];
1038
1039 let writer = TranscriptWriter::new(&path).unwrap();
1040 writer.append(0, &msg).await.unwrap();
1041
1042 assert_eq!(msg.parts.len(), 2);
1044
1045 let messages = TranscriptReader::load(&path).unwrap();
1046 assert_eq!(messages.len(), 1);
1047 assert_eq!(messages[0].parts.len(), 1);
1048 assert!(matches!(messages[0].parts[0], MessagePart::Text { .. }));
1049 assert!(
1050 !messages[0]
1051 .parts
1052 .iter()
1053 .any(|p| matches!(p, MessagePart::Image(_))),
1054 "transcript must not retain Image parts"
1055 );
1056
1057 let raw = std::fs::read_to_string(&path).unwrap();
1059 assert!(
1060 !raw.contains("mime_type") && !raw.contains("image/jpeg"),
1061 "raw image payload leaked into transcript file"
1062 );
1063 }
1064
1065 #[tokio::test]
1066 async fn append_preserves_non_image_parts() {
1067 let dir = tempfile::tempdir().unwrap();
1068 let path = dir.path().join("test.jsonl");
1069
1070 let mut msg = test_message(Role::Assistant, "used a tool");
1071 msg.parts = vec![
1072 MessagePart::Text {
1073 text: "used a tool".to_owned(),
1074 },
1075 MessagePart::ToolUse {
1076 id: "call-1".to_owned(),
1077 name: "search".to_owned(),
1078 input: serde_json::json!({"query": "rust"}),
1079 },
1080 ];
1081
1082 let writer = TranscriptWriter::new(&path).unwrap();
1083 writer.append(0, &msg).await.unwrap();
1084
1085 let messages = TranscriptReader::load(&path).unwrap();
1086 assert_eq!(messages.len(), 1);
1087 assert_eq!(messages[0].parts.len(), 2);
1088 assert!(matches!(messages[0].parts[0], MessagePart::Text { .. }));
1089 assert!(matches!(messages[0].parts[1], MessagePart::ToolUse { .. }));
1090 }
1091
1092 #[tokio::test]
1093 async fn append_empty_parts_unchanged() {
1094 let dir = tempfile::tempdir().unwrap();
1095 let path = dir.path().join("test.jsonl");
1096
1097 let msg = test_message(Role::User, "plain task message");
1100 assert!(msg.parts.is_empty());
1101
1102 let writer = TranscriptWriter::new(&path).unwrap();
1103 writer.append(0, &msg).await.unwrap();
1104
1105 let messages = TranscriptReader::load(&path).unwrap();
1106 assert_eq!(messages.len(), 1);
1107 assert!(messages[0].parts.is_empty());
1108 assert_eq!(messages[0].content, "plain task message");
1109 }
1110
1111 #[test]
1112 fn load_missing_file_no_meta_returns_empty() {
1113 let dir = tempfile::tempdir().unwrap();
1114 let path = dir.path().join("ghost.jsonl");
1115 let messages = TranscriptReader::load(&path).unwrap();
1116 assert!(messages.is_empty());
1117 }
1118
1119 #[test]
1120 fn load_missing_file_with_meta_returns_error() {
1121 let dir = tempfile::tempdir().unwrap();
1122 let meta_path = dir.path().join("ghost.meta.json");
1123 std::fs::write(&meta_path, "{}").unwrap();
1124 let jsonl_path = dir.path().join("ghost.jsonl");
1125 let err = TranscriptReader::load(&jsonl_path).unwrap_err();
1126 assert_matches!(err, SubAgentError::Transcript(_));
1127 }
1128
1129 #[test]
1130 fn load_skips_malformed_lines() {
1131 let dir = tempfile::tempdir().unwrap();
1132 let path = dir.path().join("mixed.jsonl");
1133
1134 let good = test_message(Role::User, "good");
1135 let entry = TranscriptEntry {
1136 seq: 0,
1137 timestamp: "2026-01-01T00:00:00Z".to_owned(),
1138 message: good.clone(),
1139 chain: None,
1140 };
1141 let good_line = serde_json::to_string(&entry).unwrap();
1142 let content = format!("{good_line}\nnot valid json\n{good_line}\n");
1143 std::fs::write(&path, &content).unwrap();
1144
1145 let messages = TranscriptReader::load(&path).unwrap();
1146 assert_eq!(messages.len(), 2);
1147 }
1148
1149 #[test]
1150 fn load_strict_fails_on_first_malformed_line() {
1151 let dir = tempfile::tempdir().unwrap();
1152 let path = dir.path().join("mixed.jsonl");
1153
1154 let good = test_message(Role::User, "good");
1155 let entry = TranscriptEntry {
1156 seq: 0,
1157 timestamp: "2026-01-01T00:00:00Z".to_owned(),
1158 message: good.clone(),
1159 chain: None,
1160 };
1161 let good_line = serde_json::to_string(&entry).unwrap();
1162 let content = format!("{good_line}\nnot valid json\n{good_line}\n");
1165 std::fs::write(&path, &content).unwrap();
1166
1167 let err = TranscriptReader::load_strict(&path).unwrap_err();
1168 assert_matches!(err, SubAgentError::Transcript(_));
1169
1170 let messages = TranscriptReader::load(&path).unwrap();
1173 assert_eq!(messages.len(), 2);
1174 }
1175
1176 #[test]
1177 fn load_strict_succeeds_on_intact_file() {
1178 let dir = tempfile::tempdir().unwrap();
1179 let path = dir.path().join("clean.jsonl");
1180
1181 let good = test_message(Role::User, "good");
1182 let entry = TranscriptEntry {
1183 seq: 0,
1184 timestamp: "2026-01-01T00:00:00Z".to_owned(),
1185 message: good,
1186 chain: None,
1187 };
1188 let good_line = serde_json::to_string(&entry).unwrap();
1189 std::fs::write(&path, format!("{good_line}\n")).unwrap();
1190
1191 let messages = TranscriptReader::load_strict(&path).unwrap();
1192 assert_eq!(messages.len(), 1);
1193 }
1194
1195 #[test]
1196 fn load_strict_missing_file_no_meta_returns_empty() {
1197 let dir = tempfile::tempdir().unwrap();
1198 let path = dir.path().join("ghost.jsonl");
1199 let messages = TranscriptReader::load_strict(&path).unwrap();
1200 assert!(messages.is_empty());
1201 }
1202
1203 #[test]
1204 fn meta_roundtrip() {
1205 let dir = tempfile::tempdir().unwrap();
1206 let meta = test_meta("abc-123");
1207 TranscriptWriter::write_meta(dir.path(), "abc-123", &meta).unwrap();
1208 let loaded = TranscriptReader::load_meta(dir.path(), "abc-123").unwrap();
1209 assert_eq!(loaded.agent_id, "abc-123");
1210 assert_eq!(loaded.turns_used, 2);
1211 }
1212
1213 #[test]
1214 fn meta_not_found_returns_not_found_error() {
1215 let dir = tempfile::tempdir().unwrap();
1216 let err = TranscriptReader::load_meta(dir.path(), "ghost").unwrap_err();
1217 assert_matches!(err, SubAgentError::NotFound(_));
1218 }
1219
1220 #[test]
1221 fn find_by_prefix_exact() {
1222 let dir = tempfile::tempdir().unwrap();
1223 let meta = test_meta("abcdef01-0000-0000-0000-000000000000");
1224 TranscriptWriter::write_meta(dir.path(), "abcdef01-0000-0000-0000-000000000000", &meta)
1225 .unwrap();
1226 let id =
1227 TranscriptReader::find_by_prefix(dir.path(), "abcdef01-0000-0000-0000-000000000000")
1228 .unwrap();
1229 assert_eq!(id, "abcdef01-0000-0000-0000-000000000000");
1230 }
1231
1232 #[test]
1233 fn find_by_prefix_short_prefix() {
1234 let dir = tempfile::tempdir().unwrap();
1235 let meta = test_meta("deadbeef-0000-0000-0000-000000000000");
1236 TranscriptWriter::write_meta(dir.path(), "deadbeef-0000-0000-0000-000000000000", &meta)
1237 .unwrap();
1238 let id = TranscriptReader::find_by_prefix(dir.path(), "deadbeef").unwrap();
1239 assert_eq!(id, "deadbeef-0000-0000-0000-000000000000");
1240 }
1241
1242 #[test]
1243 fn find_by_prefix_not_found() {
1244 let dir = tempfile::tempdir().unwrap();
1245 let err = TranscriptReader::find_by_prefix(dir.path(), "xxxxxxxx").unwrap_err();
1246 assert_matches!(err, SubAgentError::NotFound(_));
1247 }
1248
1249 #[test]
1250 fn find_by_prefix_ambiguous() {
1251 let dir = tempfile::tempdir().unwrap();
1252 TranscriptWriter::write_meta(dir.path(), "aabb0001-x", &test_meta("aabb0001-x")).unwrap();
1253 TranscriptWriter::write_meta(dir.path(), "aabb0002-y", &test_meta("aabb0002-y")).unwrap();
1254 let err = TranscriptReader::find_by_prefix(dir.path(), "aabb").unwrap_err();
1255 assert_matches!(err, SubAgentError::AmbiguousId(_, 2));
1256 }
1257
1258 #[test]
1259 fn sweep_old_transcripts_removes_oldest() {
1260 let dir = tempfile::tempdir().unwrap();
1261
1262 for i in 0..5u32 {
1263 let path = dir.path().join(format!("file{i:02}.jsonl"));
1264 std::fs::write(&path, b"").unwrap();
1265 }
1270
1271 let deleted = sweep_old_transcripts(dir.path(), 3).unwrap();
1272 assert_eq!(deleted, 2);
1273
1274 let remaining: Vec<_> = std::fs::read_dir(dir.path())
1275 .unwrap()
1276 .filter_map(std::result::Result::ok)
1277 .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("jsonl"))
1278 .collect();
1279 assert_eq!(remaining.len(), 3);
1280 }
1281
1282 #[test]
1283 fn sweep_with_zero_max_does_nothing() {
1284 let dir = tempfile::tempdir().unwrap();
1285 std::fs::write(dir.path().join("a.jsonl"), b"").unwrap();
1286 let deleted = sweep_old_transcripts(dir.path(), 0).unwrap();
1287 assert_eq!(deleted, 0);
1288 }
1289
1290 #[test]
1291 fn sweep_below_max_does_nothing() {
1292 let dir = tempfile::tempdir().unwrap();
1293 std::fs::write(dir.path().join("a.jsonl"), b"").unwrap();
1294 let deleted = sweep_old_transcripts(dir.path(), 50).unwrap();
1295 assert_eq!(deleted, 0);
1296 }
1297
1298 #[test]
1299 fn utc_now_format() {
1300 let ts = utc_now();
1301 assert_eq!(ts.len(), 20);
1303 assert!(ts.ends_with('Z'));
1304 assert!(ts.contains('T'));
1305 }
1306
1307 #[test]
1308 fn load_empty_file_returns_empty() {
1309 let dir = tempfile::tempdir().unwrap();
1310 let path = dir.path().join("empty.jsonl");
1311 std::fs::write(&path, b"").unwrap();
1312 let messages = TranscriptReader::load(&path).unwrap();
1313 assert!(messages.is_empty());
1314 }
1315
1316 #[test]
1317 fn load_meta_invalid_json_returns_transcript_error() {
1318 let dir = tempfile::tempdir().unwrap();
1319 std::fs::write(dir.path().join("bad.meta.json"), b"not json at all {{{{").unwrap();
1320 let err = TranscriptReader::load_meta(dir.path(), "bad").unwrap_err();
1321 assert_matches!(err, SubAgentError::Transcript(_));
1322 }
1323
1324 #[test]
1325 fn sweep_removes_companion_meta() {
1326 let dir = tempfile::tempdir().unwrap();
1327 for i in 0..4u32 {
1329 let stem = format!("file{i:02}");
1330 std::fs::write(dir.path().join(format!("{stem}.jsonl")), b"").unwrap();
1331 std::fs::write(dir.path().join(format!("{stem}.meta.json")), b"{}").unwrap();
1332 }
1333 let deleted = sweep_old_transcripts(dir.path(), 2).unwrap();
1334 assert_eq!(deleted, 2);
1335 let meta_count = std::fs::read_dir(dir.path())
1337 .unwrap()
1338 .filter_map(std::result::Result::ok)
1339 .filter(|e| e.path().to_string_lossy().ends_with(".meta.json"))
1340 .count();
1341 assert_eq!(
1342 meta_count, 2,
1343 "orphaned meta sidecars should have been removed"
1344 );
1345 }
1346
1347 #[test]
1348 fn data_loss_guard_uses_stem_based_meta_path() {
1349 let dir = tempfile::tempdir().unwrap();
1352 let agent_id = "deadbeef-0000-0000-0000-000000000000";
1353 std::fs::write(dir.path().join(format!("{agent_id}.meta.json")), b"{}").unwrap();
1355 let jsonl_path = dir.path().join(format!("{agent_id}.jsonl"));
1356 let err = TranscriptReader::load(&jsonl_path).unwrap_err();
1357 assert_matches!(err, SubAgentError::Transcript(ref m) if m.contains("missing"));
1358 }
1359
1360 #[test]
1361 fn meta_roundtrip_preserves_mcp_tool_names() {
1362 let dir = tempfile::tempdir().unwrap();
1363 let agent_id = "abc-123";
1364 let mut meta = test_meta(agent_id);
1365 meta.mcp_tool_names = vec!["search".into(), "write_file".into()];
1366 TranscriptWriter::write_meta(dir.path(), agent_id, &meta).unwrap();
1367 let loaded = TranscriptReader::load_meta(dir.path(), agent_id).unwrap();
1368 assert_eq!(loaded.mcp_tool_names, vec!["search", "write_file"]);
1369 }
1370
1371 fn test_ring(epoch: u32, byte: u8) -> Arc<ChainKeyRing> {
1378 Arc::new(ChainKeyRing::new(
1379 epoch,
1380 zeph_common::hash_chain::ChainKey::new([byte; 32]),
1381 ))
1382 }
1383
1384 #[tokio::test]
1385 async fn chained_writer_reader_roundtrip() {
1386 configure_history_integrity(Some(test_ring(0, 1)));
1387 let dir = tempfile::tempdir().unwrap();
1388 let path = dir.path().join("abc.jsonl");
1389
1390 let writer = TranscriptWriter::new(&path).unwrap();
1391 writer
1392 .append(0, &test_message(Role::User, "hello"))
1393 .await
1394 .unwrap();
1395 writer
1396 .append(1, &test_message(Role::Assistant, "world"))
1397 .await
1398 .unwrap();
1399 drop(writer);
1400
1401 let raw = std::fs::read_to_string(&path).unwrap();
1402 assert!(
1403 raw.lines().all(|l| l.contains("\"chain\":")),
1404 "every line must carry a chain field once integrity is configured"
1405 );
1406
1407 let messages = TranscriptReader::load(&path).unwrap();
1408 assert_eq!(messages.len(), 2);
1409 assert_eq!(messages[0].content, "hello");
1410 assert_eq!(messages[1].content, "world");
1411
1412 configure_history_integrity(None);
1413 }
1414
1415 #[tokio::test]
1416 async fn tamper_in_place_edit_is_detected() {
1417 configure_history_integrity(Some(test_ring(0, 2)));
1418 let dir = tempfile::tempdir().unwrap();
1419 let path = dir.path().join("abc.jsonl");
1420
1421 let writer = TranscriptWriter::new(&path).unwrap();
1422 writer
1426 .append(0, &test_message(Role::User, "untouched"))
1427 .await
1428 .unwrap();
1429 writer
1430 .append(1, &test_message(Role::Assistant, "original"))
1431 .await
1432 .unwrap();
1433 drop(writer);
1434
1435 let raw = std::fs::read_to_string(&path).unwrap();
1436 let tampered = raw.replace("original", "forged-approval");
1437 assert_ne!(raw, tampered);
1438 std::fs::write(&path, tampered).unwrap();
1439
1440 let err = TranscriptReader::load(&path).unwrap_err();
1441 assert_matches!(err, SubAgentError::Integrity(ref m) if m.contains("TAMPER"));
1442 let err = TranscriptReader::load_strict(&path).unwrap_err();
1445 assert_matches!(err, SubAgentError::Integrity(_));
1446
1447 configure_history_integrity(None);
1448 }
1449
1450 #[tokio::test]
1451 async fn legacy_file_is_auto_trusted_once_when_integrity_configured_later() {
1452 configure_history_integrity(None);
1454 let dir = tempfile::tempdir().unwrap();
1455 let path = dir.path().join("legacy.jsonl");
1456 let writer = TranscriptWriter::new(&path).unwrap();
1457 writer
1458 .append(0, &test_message(Role::User, "pre-feature message"))
1459 .await
1460 .unwrap();
1461 drop(writer);
1462
1463 let raw = std::fs::read_to_string(&path).unwrap();
1464 assert!(
1465 !raw.contains("\"chain\":"),
1466 "legacy file must carry no chain field"
1467 );
1468
1469 configure_history_integrity(Some(test_ring(0, 3)));
1471 let messages = TranscriptReader::load(&path).unwrap();
1472 assert_eq!(
1473 messages.len(),
1474 1,
1475 "legacy content must be auto-trusted, not rejected"
1476 );
1477
1478 assert!(
1481 WARNED_LEGACY_UNDER_KEY.read().unwrap().contains(&path),
1482 "path must be recorded as warned after the first legacy-under-active-key read"
1483 );
1484 let warned_count_before = WARNED_LEGACY_UNDER_KEY.read().unwrap().len();
1485 let _ = TranscriptReader::load(&path).unwrap();
1486 assert_eq!(
1487 WARNED_LEGACY_UNDER_KEY.read().unwrap().len(),
1488 warned_count_before,
1489 "a second read of the same path must not add a second warned-set entry"
1490 );
1491
1492 configure_history_integrity(None);
1493 }
1494
1495 #[tokio::test]
1496 async fn partial_strip_of_chain_field_is_detected_as_tamper() {
1497 configure_history_integrity(Some(test_ring(0, 4)));
1498 let dir = tempfile::tempdir().unwrap();
1499 let path = dir.path().join("abc.jsonl");
1500
1501 let writer = TranscriptWriter::new(&path).unwrap();
1502 writer
1503 .append(0, &test_message(Role::User, "one"))
1504 .await
1505 .unwrap();
1506 writer
1507 .append(1, &test_message(Role::Assistant, "two"))
1508 .await
1509 .unwrap();
1510 drop(writer);
1511
1512 let raw = std::fs::read_to_string(&path).unwrap();
1515 let lines: Vec<&str> = raw.lines().collect();
1516 assert_eq!(lines.len(), 2);
1517 let mut second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
1518 second.as_object_mut().unwrap().remove("chain");
1519 let stripped = format!("{}\n{}\n", lines[0], second);
1520 std::fs::write(&path, stripped).unwrap();
1521
1522 let err = TranscriptReader::load(&path).unwrap_err();
1523 assert_matches!(err, SubAgentError::Integrity(ref m) if m.contains("partial strip"));
1524
1525 configure_history_integrity(None);
1526 }
1527
1528 #[tokio::test]
1529 async fn key_unavailable_on_chained_file_fails_closed_not_legacy() {
1530 configure_history_integrity(Some(test_ring(0, 5)));
1531 let dir = tempfile::tempdir().unwrap();
1532 let path = dir.path().join("abc.jsonl");
1533 let writer = TranscriptWriter::new(&path).unwrap();
1534 writer
1535 .append(0, &test_message(Role::User, "chained"))
1536 .await
1537 .unwrap();
1538 drop(writer);
1539
1540 configure_history_integrity(None);
1542 let err = TranscriptReader::load(&path).unwrap_err();
1543 assert_matches!(err, SubAgentError::Integrity(ref m) if m.contains("NFR-004") || m.contains("no history-integrity key"));
1544 }
1545
1546 #[tokio::test]
1547 async fn rotated_key_epoch_verifies_as_rekeyed_not_tampered() {
1548 let old_key_byte = 6u8;
1549 configure_history_integrity(Some(test_ring(0, old_key_byte)));
1550 let dir = tempfile::tempdir().unwrap();
1551 let path = dir.path().join("abc.jsonl");
1552 let writer = TranscriptWriter::new(&path).unwrap();
1553 writer
1554 .append(0, &test_message(Role::User, "written before rotation"))
1555 .await
1556 .unwrap();
1557 drop(writer);
1558
1559 let ring = Arc::new(
1561 ChainKeyRing::new(1, zeph_common::hash_chain::ChainKey::new([9u8; 32])).with_previous(
1562 0,
1563 zeph_common::hash_chain::ChainKey::new([old_key_byte; 32]),
1564 ),
1565 );
1566 configure_history_integrity(Some(ring));
1567
1568 let messages = TranscriptReader::load(&path).unwrap();
1569 assert_eq!(
1570 messages.len(),
1571 1,
1572 "a legitimately re-keyed file must still verify"
1573 );
1574
1575 configure_history_integrity(None);
1576 }
1577
1578 #[tokio::test]
1579 async fn writer_reopen_seeds_chain_from_existing_tail() {
1580 configure_history_integrity(Some(test_ring(0, 7)));
1581 let dir = tempfile::tempdir().unwrap();
1582 let path = dir.path().join("abc.jsonl");
1583
1584 {
1585 let writer = TranscriptWriter::new(&path).unwrap();
1586 writer
1587 .append(0, &test_message(Role::User, "first session"))
1588 .await
1589 .unwrap();
1590 }
1591 {
1593 let writer = TranscriptWriter::new(&path).unwrap();
1594 writer
1595 .append(1, &test_message(Role::Assistant, "second session"))
1596 .await
1597 .unwrap();
1598 }
1599
1600 let messages = TranscriptReader::load(&path).unwrap();
1602 assert_eq!(messages.len(), 2);
1603
1604 configure_history_integrity(None);
1605 }
1606
1607 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
1611 async fn concurrent_append_preserves_chain_order() {
1612 const N: u32 = 50;
1613 configure_history_integrity(Some(test_ring(0, 8)));
1614 let dir = tempfile::tempdir().unwrap();
1615 let path = dir.path().join("abc.jsonl");
1616 let writer = TranscriptWriter::new(&path).unwrap();
1617
1618 let mut tasks = tokio::task::JoinSet::new();
1619 for i in 0..N {
1620 let writer = writer.clone();
1621 tasks.spawn(async move {
1622 writer
1623 .append(i, &test_message(Role::User, &format!("msg-{i}")))
1624 .await
1625 .unwrap();
1626 });
1627 }
1628 while tasks.join_next().await.is_some() {}
1629 drop(writer);
1630
1631 let messages = TranscriptReader::load(&path).unwrap();
1634 assert_eq!(messages.len(), usize::try_from(N).unwrap());
1635
1636 configure_history_integrity(None);
1637 }
1638
1639 #[derive(Default)]
1645 struct MockAnchorStore {
1646 map: std::sync::Mutex<std::collections::HashMap<String, Anchor>>,
1647 }
1648
1649 impl AnchorStore for MockAnchorStore {
1650 fn get(
1651 &self,
1652 subsystem: AnchorSubsystem,
1653 file_id: &[u8],
1654 ) -> std::pin::Pin<
1655 Box<
1656 dyn std::future::Future<
1657 Output = Result<Option<Anchor>, zeph_common::anchor::AnchorError>,
1658 > + Send
1659 + '_,
1660 >,
1661 > {
1662 let result = self.get_sync(subsystem, file_id);
1663 Box::pin(async move { result })
1664 }
1665
1666 fn get_sync(
1667 &self,
1668 subsystem: AnchorSubsystem,
1669 file_id: &[u8],
1670 ) -> Result<Option<Anchor>, zeph_common::anchor::AnchorError> {
1671 let key = zeph_common::anchor::anchor_key(subsystem, file_id);
1672 Ok(self.map.lock().unwrap().get(&key).cloned())
1673 }
1674
1675 fn put(
1676 &self,
1677 subsystem: AnchorSubsystem,
1678 file_id: &[u8],
1679 anchor: Anchor,
1680 ) -> std::pin::Pin<
1681 Box<
1682 dyn std::future::Future<Output = Result<(), zeph_common::anchor::AnchorError>>
1683 + Send
1684 + '_,
1685 >,
1686 > {
1687 let key = zeph_common::anchor::anchor_key(subsystem, file_id);
1688 self.map.lock().unwrap().insert(key, anchor);
1689 Box::pin(async { Ok(()) })
1690 }
1691
1692 fn delete(
1693 &self,
1694 subsystem: AnchorSubsystem,
1695 file_id: &[u8],
1696 ) -> std::pin::Pin<
1697 Box<
1698 dyn std::future::Future<Output = Result<(), zeph_common::anchor::AnchorError>>
1699 + Send
1700 + '_,
1701 >,
1702 > {
1703 let key = zeph_common::anchor::anchor_key(subsystem, file_id);
1704 self.map.lock().unwrap().remove(&key);
1705 Box::pin(async { Ok(()) })
1706 }
1707 }
1708
1709 #[tokio::test]
1713 async fn pre_anchor_chained_file_still_opens_with_anchor_store_online() {
1714 configure_history_integrity(Some(test_ring(0, 20)));
1715 let dir = tempfile::tempdir().unwrap();
1716 let path = dir.path().join("abc.jsonl");
1717
1718 let writer = TranscriptWriter::new(&path).unwrap();
1720 writer
1721 .append(0, &test_message(Role::User, "pre-anchor"))
1722 .await
1723 .unwrap();
1724 drop(writer);
1725
1726 configure_anchor_store(Some(Arc::new(MockAnchorStore::default())));
1728 let messages = TranscriptReader::load(&path).unwrap();
1729 assert_eq!(
1730 messages.len(),
1731 1,
1732 "absent anchor must never brick a legacy-chained file"
1733 );
1734
1735 configure_anchor_store(None);
1736 configure_history_integrity(None);
1737 }
1738
1739 #[tokio::test]
1742 async fn whole_strip_of_anchored_transcript_is_tamper() {
1743 configure_history_integrity(Some(test_ring(0, 21)));
1744 let store: Arc<dyn AnchorStore> = Arc::new(MockAnchorStore::default());
1745 configure_anchor_store(Some(Arc::clone(&store)));
1746
1747 let dir = tempfile::tempdir().unwrap();
1748 let path = dir.path().join("abc.jsonl");
1749 let writer = TranscriptWriter::new(&path).unwrap();
1750 writer
1751 .append(0, &test_message(Role::User, "one"))
1752 .await
1753 .unwrap();
1754 writer
1755 .append(1, &test_message(Role::Assistant, "two"))
1756 .await
1757 .unwrap();
1758 writer.finalize().await.unwrap();
1759
1760 let messages = TranscriptReader::load(&path).unwrap();
1762 assert_eq!(messages.len(), 2);
1763
1764 let raw = std::fs::read_to_string(&path).unwrap();
1767 let stripped: String = raw
1768 .lines()
1769 .map(|line| {
1770 let mut value: serde_json::Value = serde_json::from_str(line).unwrap();
1771 value.as_object_mut().unwrap().remove("chain");
1772 value.to_string()
1773 })
1774 .collect::<Vec<_>>()
1775 .join("\n")
1776 + "\n";
1777 std::fs::write(&path, stripped).unwrap();
1778
1779 let err = TranscriptReader::load(&path).unwrap_err();
1780 assert_matches!(err, SubAgentError::Integrity(ref m) if m.contains("TAMPER") && m.contains("vault anchor"));
1781
1782 configure_anchor_store(None);
1783 configure_history_integrity(None);
1784 }
1785
1786 #[tokio::test]
1787 async fn truncation_below_anchored_count_is_tamper() {
1788 configure_history_integrity(Some(test_ring(0, 22)));
1789 let store: Arc<dyn AnchorStore> = Arc::new(MockAnchorStore::default());
1790 configure_anchor_store(Some(Arc::clone(&store)));
1791
1792 let dir = tempfile::tempdir().unwrap();
1793 let path = dir.path().join("abc.jsonl");
1794 let writer = TranscriptWriter::new(&path).unwrap();
1795 writer
1796 .append(0, &test_message(Role::User, "one"))
1797 .await
1798 .unwrap();
1799 writer
1800 .append(1, &test_message(Role::Assistant, "two"))
1801 .await
1802 .unwrap();
1803 writer.finalize().await.unwrap();
1804
1805 let raw = std::fs::read_to_string(&path).unwrap();
1808 let first_line = raw.lines().next().unwrap();
1809 std::fs::write(&path, format!("{first_line}\n")).unwrap();
1810
1811 let err = TranscriptReader::load(&path).unwrap_err();
1812 assert_matches!(err, SubAgentError::Integrity(ref m) if m.contains("TAMPER") && m.contains("truncated"));
1813
1814 configure_anchor_store(None);
1815 configure_history_integrity(None);
1816 }
1817
1818 #[tokio::test]
1819 async fn finalize_is_noop_without_anchor_store_or_without_chaining() {
1820 configure_history_integrity(Some(test_ring(0, 23)));
1822 let dir = tempfile::tempdir().unwrap();
1823 let path = dir.path().join("abc.jsonl");
1824 let writer = TranscriptWriter::new(&path).unwrap();
1825 writer
1826 .append(0, &test_message(Role::User, "x"))
1827 .await
1828 .unwrap();
1829 writer.finalize().await.unwrap();
1830 configure_history_integrity(None);
1831
1832 let store: Arc<dyn AnchorStore> = Arc::new(MockAnchorStore::default());
1835 configure_anchor_store(Some(Arc::clone(&store)));
1836 let path2 = dir.path().join("legacy.jsonl");
1837 let writer2 = TranscriptWriter::new(&path2).unwrap();
1838 writer2
1839 .append(0, &test_message(Role::User, "legacy"))
1840 .await
1841 .unwrap();
1842 writer2.finalize().await.unwrap();
1843 assert!(
1844 store
1845 .get_sync(AnchorSubsystem::SubagentTranscript, b"legacy")
1846 .unwrap()
1847 .is_none(),
1848 "no anchor should be written for an unchained writer"
1849 );
1850
1851 configure_anchor_store(None);
1852 }
1853}