1use std::collections::{HashMap, HashSet, VecDeque};
27use std::path::Path;
28
29use anyhow::{Context, Result};
30use rusqlite::{Connection, params};
31use serde::Serialize;
32
33use crate::cli::CliOutput;
34use crate::identity::sign::SignableLink;
35
36#[derive(clap::Args, Debug)]
42pub struct VerifyChainArgs {
43 pub memory_id: String,
45
46 #[arg(long, value_name = "FORMAT", default_value = "text")]
48 pub format: String,
49
50 #[arg(long)]
52 pub include_signed_events: bool,
53}
54
55#[derive(Debug, Serialize)]
62pub struct EdgeResult {
63 pub source_id: String,
64 pub target_id: String,
65 pub signature_hex: Option<String>,
67 pub attest_level: String,
68 pub verified: bool,
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub failure_reason: Option<String>,
72}
73
74#[derive(Debug, Serialize)]
76pub struct SignedEventSummary {
77 pub memory_id: String,
78 pub event_id: String,
79 pub event_type: String,
80 pub attest_level: String,
81 pub timestamp: String,
82 pub signature_present: bool,
83}
84
85#[derive(Debug, Serialize)]
88pub struct ChainReport {
89 pub ok: bool,
99 pub root_id: String,
101 pub n_memories: usize,
103 pub chain_depth: usize,
105 pub edges_verified: usize,
108 pub edges_failed: usize,
110 pub edges: Vec<EdgeResult>,
112 pub max_reflection_depth_per_namespace: HashMap<String, i32>,
114 pub bounded_status: String,
118 #[serde(skip_serializing_if = "Vec::is_empty")]
120 pub signed_events: Vec<SignedEventSummary>,
121 pub generated_at: String,
123}
124
125fn bytes_to_hex(bytes: &[u8]) -> String {
133 bytes.iter().map(|b| format!("{b:02x}")).collect()
134}
135
136fn fetch_memory_meta(conn: &Connection, id: &str) -> Result<Option<(String, String, i32)>> {
139 let mut stmt =
140 conn.prepare("SELECT id, namespace, reflection_depth FROM memories WHERE id = ?1")?;
141 let mut rows = stmt.query(params![id])?;
142 if let Some(row) = rows.next()? {
143 Ok(Some((
144 row.get::<_, String>(0)?,
145 row.get::<_, String>(1)?,
146 row.get::<_, i32>(2).unwrap_or(0),
147 )))
148 } else {
149 Ok(None)
150 }
151}
152
153struct EdgeRow {
155 target_id: String,
156 signature: Option<Vec<u8>>,
157 observed_by: Option<String>,
158 attest_level: Option<String>,
159 valid_from: Option<String>,
160 valid_until: Option<String>,
161}
162
163fn fetch_reflects_on_edges(conn: &Connection, source_id: &str) -> Result<Vec<EdgeRow>> {
169 let mut stmt = conn.prepare(
170 "SELECT target_id, signature, observed_by, attest_level, valid_from, valid_until \
171 FROM memory_links \
172 WHERE source_id = ?1 AND relation = 'reflects_on'",
173 )?;
174 let rows = stmt.query_map(params![source_id], |row| {
175 Ok(EdgeRow {
176 target_id: row.get::<_, String>(0)?,
177 signature: row.get::<_, Option<Vec<u8>>>(1)?,
178 observed_by: row.get::<_, Option<String>>(2)?,
179 attest_level: row.get::<_, Option<String>>(3)?,
180 valid_from: row.get::<_, Option<String>>(4)?,
181 valid_until: row.get::<_, Option<String>>(5)?,
182 })
183 })?;
184 rows.collect::<rusqlite::Result<Vec<_>>>()
185 .map_err(Into::into)
186}
187
188fn fetch_signed_events_for(conn: &Connection, ids: &[String]) -> Result<Vec<SignedEventSummary>> {
194 if ids.is_empty() {
195 return Ok(Vec::new());
196 }
197 let placeholders: String = ids
201 .iter()
202 .enumerate()
203 .map(|(i, _)| format!("?{}", i + 1))
204 .collect::<Vec<_>>()
205 .join(", ");
206 let sql = format!(
207 "SELECT id, agent_id, event_type, payload_hash, signature, attest_level, timestamp \
208 FROM signed_events \
209 WHERE agent_id IN ({placeholders}) \
210 ORDER BY timestamp ASC, id ASC \
211 LIMIT 1000"
212 );
213 let mut stmt = conn.prepare(&sql)?;
214 let param_refs: Vec<&dyn rusqlite::ToSql> =
215 ids.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
216 let rows = stmt.query_map(param_refs.as_slice(), |row| {
217 Ok(SignedEventSummary {
218 event_id: row.get::<_, String>(0)?,
219 memory_id: row.get::<_, String>(1)?,
220 event_type: row.get::<_, String>(2)?,
221 signature_present: row.get::<_, Option<Vec<u8>>>(4)?.is_some(),
223 attest_level: row.get::<_, String>(5)?,
224 timestamp: row.get::<_, String>(6)?,
225 })
226 })?;
227 rows.collect::<rusqlite::Result<Vec<_>>>()
228 .map_err(Into::into)
229}
230
231fn governance_cap_for_namespace(conn: &Connection, namespace: &str) -> Option<u32> {
239 crate::db::resolve_governance_policy(conn, namespace).and_then(|p| p.core.max_reflection_depth)
242}
243
244pub fn build_chain_report(
255 conn: &Connection,
256 root_id: &str,
257 include_signed_events: bool,
258) -> Result<ChainReport> {
259 build_chain_report_at(conn, root_id, include_signed_events, None)
260}
261
262pub fn build_chain_report_at(
272 conn: &Connection,
273 root_id: &str,
274 include_signed_events: bool,
275 generated_at_override: Option<&str>,
276) -> Result<ChainReport> {
277 let generated_at = generated_at_override
278 .map(ToString::to_string)
279 .unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
280
281 let mut visited: HashSet<String> = HashSet::new();
282 let mut queue: VecDeque<(String, usize)> = VecDeque::new();
283 queue.push_back((root_id.to_string(), 0));
284
285 let mut edges: Vec<EdgeResult> = Vec::new();
286 let mut max_depth_per_ns: HashMap<String, i32> = HashMap::new();
287 let mut chain_depth: usize = 0;
288 let mut all_ids: Vec<String> = Vec::new();
289 let mut any_governance_row = false;
290 let mut cap_exceeded = false;
291
292 while let Some((current_id, hop)) = queue.pop_front() {
293 if visited.contains(¤t_id) {
294 continue;
295 }
296 visited.insert(current_id.clone());
297 all_ids.push(current_id.clone());
298
299 if hop > chain_depth {
300 chain_depth = hop;
301 }
302
303 if let Some((_id, ns, rd)) = fetch_memory_meta(conn, ¤t_id)? {
305 let entry = max_depth_per_ns.entry(ns.clone()).or_insert(0_i32);
306 if rd > *entry {
307 *entry = rd;
308 }
309 if let Some(cap) = governance_cap_for_namespace(conn, &ns) {
310 any_governance_row = true;
311 #[allow(clippy::cast_sign_loss)]
312 if rd > 0 && rd as u32 > cap {
313 cap_exceeded = true;
314 }
315 }
316 }
317
318 let out_edges = fetch_reflects_on_edges(conn, ¤t_id)?;
320 for row in out_edges {
321 let attest_level = row
322 .attest_level
323 .clone()
324 .unwrap_or_else(|| crate::models::AttestLevel::Unsigned.as_str().to_string());
325
326 let (verified, failure_reason, signature_hex) = verify_edge(
327 ¤t_id,
328 &row.target_id,
329 row.signature.as_deref(),
330 row.observed_by.as_deref(),
331 row.valid_from.as_deref(),
332 row.valid_until.as_deref(),
333 &attest_level,
334 );
335
336 let target_id = row.target_id.clone();
337 edges.push(EdgeResult {
338 source_id: current_id.clone(),
339 target_id: target_id.clone(),
340 signature_hex,
341 attest_level,
342 verified,
343 failure_reason,
344 });
345
346 if !visited.contains(&target_id) {
347 queue.push_back((target_id, hop + 1));
348 }
349 }
350 }
351
352 let edges_failed = edges.iter().filter(|e| !e.verified).count();
353 let edges_verified = edges.len() - edges_failed;
354
355 let bounded_status = if cap_exceeded {
356 "exceeded_cap"
357 } else if any_governance_row {
358 "within_cap"
359 } else {
360 "no_cap_configured"
361 }
362 .to_string();
363
364 let signed_events = if include_signed_events {
365 fetch_signed_events_for(conn, &all_ids).unwrap_or_default()
366 } else {
367 Vec::new()
368 };
369
370 let ok = edges_failed == 0 && bounded_status != "exceeded_cap";
375 Ok(ChainReport {
376 ok,
377 root_id: root_id.to_string(),
378 n_memories: visited.len(),
379 chain_depth,
380 edges_verified,
381 edges_failed,
382 edges,
383 max_reflection_depth_per_namespace: max_depth_per_ns,
384 bounded_status,
385 signed_events,
386 generated_at,
387 })
388}
389
390fn verify_edge(
407 source_id: &str,
408 target_id: &str,
409 sig_blob: Option<&[u8]>,
410 observed_by: Option<&str>,
411 valid_from: Option<&str>,
412 valid_until: Option<&str>,
413 attest_level: &str,
414) -> (bool, Option<String>, Option<String>) {
415 let signature_hex = sig_blob.map(bytes_to_hex);
416
417 let Some(sig) = sig_blob else {
419 return (true, None, None);
420 };
421
422 let Some(agent_id) = observed_by else {
423 return (
424 false,
425 Some(
426 "signature present but observed_by is NULL — \
427 cannot resolve public key"
428 .to_string(),
429 ),
430 signature_hex,
431 );
432 };
433
434 if agent_id.is_empty() {
435 return (
436 false,
437 Some("observed_by is empty — cannot resolve public key".to_string()),
438 signature_hex,
439 );
440 }
441
442 let pub_key = crate::identity::verify::lookup_peer_public_key(agent_id);
443 let Some(pub_key) = pub_key else {
444 return (
445 false,
446 Some(format!(
447 "no public key enrolled for '{agent_id}' \
448 (attest_level={attest_level})"
449 )),
450 signature_hex,
451 );
452 };
453
454 let link = SignableLink {
455 src_id: source_id,
456 dst_id: target_id,
457 relation: crate::models::MemoryLinkRelation::ReflectsOn.as_str(),
458 observed_by: Some(agent_id),
459 valid_from,
460 valid_until,
461 };
462
463 match crate::identity::verify::verify(&pub_key, &link, sig) {
464 Ok(()) => (true, None, signature_hex),
465 Err(e) => (false, Some(e.to_string()), signature_hex),
466 }
467}
468
469pub(super) fn render_text(report: &ChainReport, out: &mut CliOutput<'_>) -> Result<()> {
474 writeln!(
475 out.stdout,
476 "verify-reflection-chain: root={} memories={} depth={} edges={} failed={}",
477 report.root_id,
478 report.n_memories,
479 report.chain_depth,
480 report.edges.len(),
481 report.edges_failed,
482 )?;
483 writeln!(out.stdout, "bounded_status: {}", report.bounded_status)?;
484 writeln!(out.stdout, "generated_at: {}", report.generated_at)?;
485
486 if !report.max_reflection_depth_per_namespace.is_empty() {
487 writeln!(out.stdout, "\nmax_reflection_depth per namespace:")?;
488 let mut ns_vec: Vec<_> = report.max_reflection_depth_per_namespace.iter().collect();
489 ns_vec.sort_by_key(|(ns, _)| ns.as_str());
490 for (ns, depth) in ns_vec {
491 writeln!(out.stdout, " {ns}: {depth}")?;
492 }
493 }
494
495 if !report.edges.is_empty() {
496 writeln!(out.stdout, "\nedges:")?;
497 for e in &report.edges {
498 let status = if e.verified { "OK" } else { "FAIL" };
499 let src_short = &e.source_id[..e.source_id.len().min(8)];
500 let tgt_short = &e.target_id[..e.target_id.len().min(8)];
501 write!(
502 out.stdout,
503 " [{status}] {src_short} -> {tgt_short} attest={}",
504 e.attest_level,
505 )?;
506 if let Some(ref reason) = e.failure_reason {
507 write!(out.stdout, " reason=\"{reason}\"")?;
508 }
509 writeln!(out.stdout)?;
510 }
511 }
512
513 if !report.signed_events.is_empty() {
514 writeln!(
515 out.stdout,
516 "\nsigned_events ({} rows):",
517 report.signed_events.len()
518 )?;
519 for ev in &report.signed_events {
520 writeln!(
521 out.stdout,
522 " {} | {} | {} | sig={}",
523 ev.event_id,
524 ev.event_type,
525 ev.timestamp,
526 if ev.signature_present { "yes" } else { "no" }
527 )?;
528 }
529 }
530 Ok(())
531}
532
533pub fn run(db_path: &Path, args: &VerifyChainArgs, out: &mut CliOutput<'_>) -> Result<i32> {
552 let json = args.format.to_ascii_lowercase() == "json";
553 let conn = crate::db::open(db_path).context("open db")?;
554
555 let report = build_chain_report(&conn, &args.memory_id, args.include_signed_events)?;
556
557 if json {
558 let payload = serde_json::to_string_pretty(&report).context("serialise chain report")?;
559 writeln!(out.stdout, "{payload}")?;
560 } else {
561 render_text(&report, out)?;
562 }
563
564 if report.ok { Ok(0) } else { Ok(2) }
568}
569
570#[cfg(test)]
575mod tests {
576 use super::*;
577 use chrono::Utc;
578 use rusqlite::params;
579 use tempfile::TempDir;
580
581 use crate::db;
582 use crate::identity::keypair as kp_mod;
583 use crate::identity::sign;
584 use crate::models::{Memory, Tier};
585
586 fn open_test_db(tmp: &TempDir) -> (rusqlite::Connection, std::path::PathBuf) {
587 let db_path = tmp.path().join("ai-memory.db");
588 let conn = db::open(&db_path).expect("db::open");
589 (conn, db_path)
590 }
591
592 fn insert_mem(conn: &rusqlite::Connection, ns: &str, depth: i32) -> String {
593 let id = uuid::Uuid::new_v4().to_string();
594 let now = Utc::now().to_rfc3339();
595 let mem = Memory {
596 id: id.clone(),
597 tier: Tier::Mid,
598 namespace: ns.to_string(),
599 title: format!("t-{depth}"),
600 content: format!("c-{depth}"),
601 reflection_depth: depth,
602 created_at: now.clone(),
603 updated_at: now,
604 ..Default::default()
605 };
606 db::insert(conn, &mem).expect("insert");
607 id
608 }
609
610 fn link_unsigned(conn: &rusqlite::Connection, src: &str, tgt: &str) {
611 conn.execute(
612 "INSERT OR IGNORE INTO memory_links \
613 (source_id, target_id, relation, created_at, attest_level) \
614 VALUES (?1, ?2, 'reflects_on', ?3, 'unsigned')",
615 params![src, tgt, Utc::now().to_rfc3339()],
616 )
617 .expect("link_unsigned");
618 }
619
620 fn set_cap(conn: &rusqlite::Connection, ns: &str, cap: u32) {
624 use crate::models::default_metadata;
625 let now = Utc::now().to_rfc3339();
626 let policy = crate::models::GovernancePolicy {
627 core: crate::models::CorePolicy {
628 max_reflection_depth: Some(cap),
629 ..crate::models::CorePolicy::default()
630 },
631 ..crate::models::GovernancePolicy::default()
632 };
633 let mut metadata = default_metadata();
634 if let Some(obj) = metadata.as_object_mut() {
635 obj.insert("agent_id".into(), serde_json::Value::String("test".into()));
636 obj.insert("governance".into(), serde_json::to_value(&policy).unwrap());
637 }
638 let standard = Memory {
639 id: uuid::Uuid::new_v4().to_string(),
640 tier: Tier::Long,
641 namespace: format!("_standards-{ns}"),
642 title: format!("standard for {ns}"),
643 content: "policy".into(),
644 created_at: now.clone(),
645 updated_at: now,
646 metadata,
647 ..Default::default()
648 };
649 let sid = db::insert(conn, &standard).expect("insert standard");
650 db::set_namespace_standard(conn, ns, &sid, None).expect("set_namespace_standard");
651 }
652
653 #[test]
654 fn single_memory_no_edges_gives_empty_report() {
655 let tmp = TempDir::new().unwrap();
656 let (conn, _) = open_test_db(&tmp);
657 let id = insert_mem(&conn, "ns", 0);
658
659 let report = build_chain_report(&conn, &id, false).expect("report");
660
661 assert_eq!(report.root_id, id);
662 assert_eq!(report.n_memories, 1);
663 assert_eq!(report.chain_depth, 0);
664 assert_eq!(report.edges.len(), 0);
665 assert_eq!(report.edges_failed, 0);
666 assert_eq!(report.edges_verified, 0);
667 assert_eq!(report.bounded_status, "no_cap_configured");
668 assert!(report.signed_events.is_empty());
669 }
670
671 #[test]
672 fn unsigned_chain_depth2_all_verified() {
673 let tmp = TempDir::new().unwrap();
674 let (conn, _) = open_test_db(&tmp);
675 let d0 = insert_mem(&conn, "ns", 0);
676 let d1 = insert_mem(&conn, "ns", 1);
677 let d2 = insert_mem(&conn, "ns", 2);
678 link_unsigned(&conn, &d2, &d1);
679 link_unsigned(&conn, &d1, &d0);
680
681 let report = build_chain_report(&conn, &d2, false).expect("report");
682
683 assert_eq!(report.n_memories, 3);
684 assert_eq!(report.chain_depth, 2);
685 assert_eq!(report.edges_failed, 0);
686 assert!(report.edges.iter().all(|e| e.verified));
688 }
689
690 #[test]
691 fn cap_exceeded_reported_in_bounded_status() {
692 let tmp = TempDir::new().unwrap();
693 let (conn, _) = open_test_db(&tmp);
694 set_cap(&conn, "cap-ns", 0);
695 let d0 = insert_mem(&conn, "cap-ns", 0);
696 let d1 = insert_mem(&conn, "cap-ns", 1); link_unsigned(&conn, &d1, &d0);
698
699 let report = build_chain_report(&conn, &d1, false).expect("report");
700
701 assert_eq!(report.bounded_status, "exceeded_cap");
702 }
703
704 #[test]
705 fn tampered_sig_edge_marked_failed() {
706 let _g = kp_mod::key_dir_env_lock()
710 .lock()
711 .unwrap_or_else(std::sync::PoisonError::into_inner);
712 let tmp = TempDir::new().unwrap();
713 let keys_tmp = TempDir::new().unwrap();
714 let (conn, _) = open_test_db(&tmp);
715
716 let agent = kp_mod::generate("tester-l13").expect("gen");
717 kp_mod::save(&agent, keys_tmp.path()).expect("save");
718
719 let d0 = insert_mem(&conn, "ns", 0);
720 let d1 = insert_mem(&conn, "ns", 1);
721
722 let now = Utc::now().to_rfc3339();
723 let link = sign::SignableLink {
724 src_id: &d1,
725 dst_id: &d0,
726 relation: "reflects_on",
727 observed_by: Some(&agent.agent_id),
728 valid_from: Some(&now),
729 valid_until: None,
730 };
731 let mut sig = sign::sign(&agent, &link).expect("sign");
732 sig[0] ^= 0x01; conn.execute(
735 "INSERT OR IGNORE INTO memory_links \
736 (source_id, target_id, relation, created_at, valid_from, \
737 signature, observed_by, attest_level) \
738 VALUES (?1, ?2, 'reflects_on', ?3, ?3, ?4, ?5, 'self_signed')",
739 params![d1, d0, now, sig, agent.agent_id],
740 )
741 .expect("insert tampered");
742
743 unsafe {
745 std::env::set_var("AI_MEMORY_KEY_DIR", keys_tmp.path());
746 }
747 let report = build_chain_report(&conn, &d1, false).expect("report");
748 unsafe {
749 std::env::remove_var("AI_MEMORY_KEY_DIR");
750 }
751
752 assert_eq!(report.edges_failed, 1, "tampered edge must count as failed");
753 assert!(
754 report.edges[0].failure_reason.is_some(),
755 "tampered edge must carry a reason"
756 );
757 }
758
759 #[test]
760 fn include_signed_events_flag_returns_vec() {
761 let tmp = TempDir::new().unwrap();
762 let (conn, _) = open_test_db(&tmp);
763 let id = insert_mem(&conn, "se-ns", 0);
764
765 let r = build_chain_report(&conn, &id, false).expect("report");
767 assert!(r.signed_events.is_empty());
768
769 let r2 = build_chain_report(&conn, &id, true).expect("report-se");
772 let _ = r2.signed_events; }
774
775 #[test]
776 fn bytes_to_hex_matches_format_pattern() {
777 let b = vec![0x00, 0x0f, 0xff, 0xab];
778 assert_eq!(bytes_to_hex(&b), "000fffab");
779 }
780
781 #[test]
794 fn fetch_memory_meta_returns_none_for_unknown_id() {
795 let tmp = TempDir::new().unwrap();
796 let (conn, _) = open_test_db(&tmp);
797 let r = fetch_memory_meta(&conn, "nonexistent-id-xxxxxx").expect("query");
798 assert!(r.is_none(), "unknown id must return None");
799 }
800
801 #[test]
802 fn fetch_signed_events_for_empty_ids_returns_empty() {
803 let tmp = TempDir::new().unwrap();
804 let (conn, _) = open_test_db(&tmp);
805 let v = fetch_signed_events_for(&conn, &[]).expect("call");
806 assert!(v.is_empty());
807 }
808
809 #[test]
810 fn fetch_signed_events_for_seeded_rows_returns_summaries() {
811 let tmp = TempDir::new().unwrap();
814 let (conn, _) = open_test_db(&tmp);
815 let agent_id = "seeded-actor";
816 let payload = b"hello";
817 let event = crate::signed_events::SignedEvent {
818 id: uuid::Uuid::new_v4().to_string(),
819 agent_id: agent_id.to_string(),
820 event_type: crate::signed_events::event_types::MEMORY_LINK_CREATED.to_string(),
821 payload_hash: crate::signed_events::payload_hash(payload),
822 signature: Some(vec![0xab; 64]),
823 attest_level: "self_signed".to_string(),
824 timestamp: chrono::Utc::now().to_rfc3339(),
825 ..crate::signed_events::SignedEvent::default()
826 };
827 crate::signed_events::append_signed_event(&conn, &event).expect("append");
828
829 let v =
830 fetch_signed_events_for(&conn, &[agent_id.to_string()]).expect("fetch with seeded row");
831 assert_eq!(v.len(), 1);
832 assert!(v[0].signature_present, "signature blob should be detected");
833 assert_eq!(v[0].memory_id, agent_id);
834 }
835
836 #[test]
837 fn verify_edge_unsigned_returns_verified_with_no_reason() {
838 let (verified, reason, sig_hex) = verify_edge(
839 "src-id",
840 "tgt-id",
841 None,
842 Some("alice"),
843 None,
844 None,
845 "unsigned",
846 );
847 assert!(verified);
848 assert!(reason.is_none());
849 assert!(sig_hex.is_none());
850 }
851
852 #[test]
853 fn verify_edge_signed_but_no_observed_by_fails() {
854 let sig = vec![0xff; 64];
855 let (verified, reason, sig_hex) =
856 verify_edge("src", "tgt", Some(&sig), None, None, None, "self_signed");
857 assert!(!verified);
858 let reason = reason.expect("reason set");
859 assert!(reason.contains("observed_by is NULL"), "got: {reason}");
860 assert!(sig_hex.is_some());
861 }
862
863 #[test]
864 fn verify_edge_signed_with_empty_observed_by_fails() {
865 let sig = vec![0xff; 64];
866 let (verified, reason, _) = verify_edge(
867 "src",
868 "tgt",
869 Some(&sig),
870 Some(""),
871 None,
872 None,
873 "self_signed",
874 );
875 assert!(!verified);
876 let reason = reason.expect("reason set");
877 assert!(reason.contains("empty"), "got: {reason}");
878 }
879
880 #[test]
881 fn verify_edge_signed_with_unknown_agent_fails() {
882 let _g = kp_mod::key_dir_env_lock()
887 .lock()
888 .unwrap_or_else(std::sync::PoisonError::into_inner);
889 let keys_tmp = TempDir::new().unwrap();
890 unsafe {
894 std::env::set_var("AI_MEMORY_KEY_DIR", keys_tmp.path());
895 }
896 let sig = vec![0xff; 64];
897 let (verified, reason, _) = verify_edge(
898 "src",
899 "tgt",
900 Some(&sig),
901 Some("never-enrolled-agent"),
902 None,
903 None,
904 "self_signed",
905 );
906 unsafe {
907 std::env::remove_var("AI_MEMORY_KEY_DIR");
908 }
909 assert!(!verified);
910 let reason = reason.expect("reason set");
911 assert!(reason.contains("no public key enrolled"), "got: {reason}");
912 }
913
914 #[test]
915 fn render_text_emits_ns_table_and_failure_reasons() {
916 use std::collections::HashMap;
921 let mut ns = HashMap::new();
922 ns.insert("ns-one".to_string(), 3);
923 ns.insert("ns-two".to_string(), 1);
924 let report = ChainReport {
925 ok: false,
926 root_id: "0123456789abcdef0123".to_string(),
927 n_memories: 2,
928 chain_depth: 1,
929 edges_verified: 0,
930 edges_failed: 1,
931 edges: vec![EdgeResult {
932 source_id: "src-id-long-1234".to_string(),
933 target_id: "tgt-id-long-5678".to_string(),
934 signature_hex: Some("aabb".to_string()),
935 attest_level: "self_signed".to_string(),
936 verified: false,
937 failure_reason: Some("tampered".to_string()),
938 }],
939 max_reflection_depth_per_namespace: ns,
940 bounded_status: "within_cap".to_string(),
941 signed_events: vec![SignedEventSummary {
942 memory_id: "agent-x".to_string(),
943 event_id: "ev-1".to_string(),
944 event_type: crate::signed_events::event_types::MEMORY_STORED.to_string(),
945 attest_level: "self_signed".to_string(),
946 timestamp: "2026-05-13T00:00:00Z".to_string(),
947 signature_present: true,
948 }],
949 generated_at: "2026-05-13T00:00:00Z".to_string(),
950 };
951 let mut stdout = Vec::<u8>::new();
952 let mut stderr = Vec::<u8>::new();
953 let mut out = crate::cli::CliOutput::from_std(&mut stdout, &mut stderr);
954 render_text(&report, &mut out).expect("render");
955 let s = String::from_utf8(stdout).unwrap();
956 assert!(s.contains("ns-one: 3"), "ns table line missing: {s}");
957 assert!(s.contains("ns-two: 1"), "ns table line missing: {s}");
958 assert!(s.contains("FAIL"), "edge status missing: {s}");
959 assert!(s.contains("tampered"), "failure reason missing: {s}");
960 assert!(s.contains("signed_events"), "signed_events footer: {s}");
961 assert!(s.contains("ev-1"), "event id missing: {s}");
962 assert!(s.contains("sig=yes"), "signature flag: {s}");
963 }
964
965 #[test]
966 fn render_text_signed_event_without_signature_says_no() {
967 let report = ChainReport {
969 ok: true,
970 root_id: "root-id-here".to_string(),
971 n_memories: 1,
972 chain_depth: 0,
973 edges_verified: 0,
974 edges_failed: 0,
975 edges: vec![],
976 max_reflection_depth_per_namespace: std::collections::HashMap::new(),
977 bounded_status: "no_cap_configured".to_string(),
978 signed_events: vec![SignedEventSummary {
979 memory_id: "agent-y".to_string(),
980 event_id: "ev-2".to_string(),
981 event_type: crate::signed_events::event_types::MEMORY_TOUCH.to_string(),
982 attest_level: "unsigned".to_string(),
983 timestamp: "2026-05-13T01:00:00Z".to_string(),
984 signature_present: false,
985 }],
986 generated_at: "2026-05-13T00:00:00Z".to_string(),
987 };
988 let mut stdout = Vec::<u8>::new();
989 let mut stderr = Vec::<u8>::new();
990 let mut out = crate::cli::CliOutput::from_std(&mut stdout, &mut stderr);
991 render_text(&report, &mut out).expect("render");
992 let s = String::from_utf8(stdout).unwrap();
993 assert!(s.contains("sig=no"), "must mark unsigned event: {s}");
994 }
995
996 #[test]
997 fn run_json_format_emits_pretty_payload() {
998 let tmp = TempDir::new().unwrap();
1000 let (_, db_path) = open_test_db(&tmp);
1001 let id = insert_mem(&open_test_db(&tmp).0, "ns", 0);
1002
1003 let args = VerifyChainArgs {
1005 memory_id: id,
1006 format: "json".to_string(),
1007 include_signed_events: false,
1008 };
1009 let mut stdout = Vec::<u8>::new();
1010 let mut stderr = Vec::<u8>::new();
1011 let mut out = crate::cli::CliOutput::from_std(&mut stdout, &mut stderr);
1012 let _ = run(&db_path, &args, &mut out);
1017 }
1020
1021 #[test]
1022 fn run_against_real_db_emits_text_report_and_exit_0() {
1023 let tmp = TempDir::new().unwrap();
1026 let (conn, db_path) = open_test_db(&tmp);
1027 let d0 = insert_mem(&conn, "ns", 0);
1028 let d1 = insert_mem(&conn, "ns", 1);
1029 link_unsigned(&conn, &d1, &d0);
1030 drop(conn);
1033
1034 let args = VerifyChainArgs {
1035 memory_id: d1,
1036 format: "text".to_string(),
1037 include_signed_events: false,
1038 };
1039 let mut stdout = Vec::<u8>::new();
1040 let mut stderr = Vec::<u8>::new();
1041 let mut out = crate::cli::CliOutput::from_std(&mut stdout, &mut stderr);
1042 let code = run(&db_path, &args, &mut out).expect("run");
1043 assert_eq!(code, 0);
1044 let s = String::from_utf8(stdout).unwrap();
1045 assert!(s.contains("verify-reflection-chain"));
1046 assert!(s.contains("memories=2"));
1047 }
1048
1049 #[test]
1050 fn run_with_cap_exceeded_returns_exit_code_1() {
1051 let tmp = TempDir::new().unwrap();
1053 let (conn, db_path) = open_test_db(&tmp);
1054 set_cap(&conn, "limit-ns", 0);
1055 let d0 = insert_mem(&conn, "limit-ns", 0);
1056 let d1 = insert_mem(&conn, "limit-ns", 1);
1057 link_unsigned(&conn, &d1, &d0);
1058 drop(conn);
1059
1060 let args = VerifyChainArgs {
1061 memory_id: d1,
1062 format: "json".to_string(),
1063 include_signed_events: false,
1064 };
1065 let mut stdout = Vec::<u8>::new();
1066 let mut stderr = Vec::<u8>::new();
1067 let mut out = crate::cli::CliOutput::from_std(&mut stdout, &mut stderr);
1068 let code = run(&db_path, &args, &mut out).expect("run");
1069 assert_eq!(code, 2, "exceeded cap must exit 2");
1071 }
1072
1073 #[test]
1074 fn run_json_format_with_include_signed_events_emits_field() {
1075 let tmp = TempDir::new().unwrap();
1078 let (conn, db_path) = open_test_db(&tmp);
1079 let id = insert_mem(&conn, "ns", 0);
1080 drop(conn);
1081
1082 let args = VerifyChainArgs {
1083 memory_id: id,
1084 format: "json".to_string(),
1085 include_signed_events: true,
1086 };
1087 let mut stdout = Vec::<u8>::new();
1088 let mut stderr = Vec::<u8>::new();
1089 let mut out = crate::cli::CliOutput::from_std(&mut stdout, &mut stderr);
1090 let code = run(&db_path, &args, &mut out).expect("run");
1091 assert_eq!(code, 0);
1092 let s = String::from_utf8(stdout).unwrap();
1093 assert!(s.contains("\"root_id\""), "got: {s}");
1095 assert!(s.contains("\"bounded_status\""), "got: {s}");
1096 }
1097}