Skip to main content

ai_memory/cli/
verify.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `ai-memory verify-reflection-chain` — external verifier for
5//! reflection chains (procurement-grade audit tool, v0.7.0 L1-3).
6//!
7//! Walks the `reflects_on` edges backward from the supplied memory to
8//! depth 0, verifies each Ed25519 signature (when present) using the
9//! `identity::verify` infrastructure, optionally checks `signed_events`
10//! creation entries, and emits a structured chain-integrity report.
11//!
12//! ## Exit codes
13//!
14//! - `0` — chain fully verified (or no signatures present and
15//!   `bounded_status != "exceeded_cap"`).
16//! - `1` — at least one edge failed signature verification, or the
17//!   chain exceeds its namespace `max_reflection_depth` cap.
18//!
19//! ## Output formats
20//!
21//! - `--format text` (default) — human-readable report printed to
22//!   stdout.
23//! - `--format json` — structured `AgenticMem Attest` tier evidence
24//!   packet serialised as JSON.
25
26use 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// ─────────────────────────────────────────────────────────────────────
37// CLI argument struct (consumed by daemon_runtime)
38// ─────────────────────────────────────────────────────────────────────
39
40/// Arguments for `ai-memory verify-reflection-chain`.
41#[derive(clap::Args, Debug)]
42pub struct VerifyChainArgs {
43    /// Memory id to start the chain walk from.
44    pub memory_id: String,
45
46    /// Output format: `json` or `text`.
47    #[arg(long, value_name = "FORMAT", default_value = "text")]
48    pub format: String,
49
50    /// Include `signed_events` creation entries in the report.
51    #[arg(long)]
52    pub include_signed_events: bool,
53}
54
55// ─────────────────────────────────────────────────────────────────────
56// Report types
57// ─────────────────────────────────────────────────────────────────────
58
59/// One `reflects_on` edge in the ancestry tree, with its verification
60/// result.
61#[derive(Debug, Serialize)]
62pub struct EdgeResult {
63    pub source_id: String,
64    pub target_id: String,
65    /// Signature bytes as hex, or `null` when the edge is unsigned.
66    pub signature_hex: Option<String>,
67    pub attest_level: String,
68    pub verified: bool,
69    /// Human-readable reason when `verified = false`.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub failure_reason: Option<String>,
72}
73
74/// Per-`signed_events` row summary for a memory in the chain.
75#[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/// Full chain-integrity report — the `AgenticMem Attest` tier evidence
86/// packet.
87#[derive(Debug, Serialize)]
88pub struct ChainReport {
89    /// v0.7.0 G-PHASE-E-4 (#709) — top-line PASS/FAIL flag for the
90    /// chain. `true` when every edge verified AND no namespace
91    /// exceeded its governance cap. Surfaced as the first field on
92    /// the JSON wire shape so external auditors / CI scripts can
93    /// `jq '.ok'` instead of recomputing the predicate from `edges_failed`
94    /// + `bounded_status`. The shell exit code mirrors this field: `0`
95    /// when `ok = true`, `2` when `ok = false` (matching the
96    /// `verify-forensic-bundle` exit convention, also raised to `2`
97    /// in #709).
98    pub ok: bool,
99    /// Root memory id supplied on the command line.
100    pub root_id: String,
101    /// Total number of distinct memories visited (root + ancestors).
102    pub n_memories: usize,
103    /// Longest path from root to a depth-0 memory.
104    pub chain_depth: usize,
105    /// Number of `reflects_on` edges that passed Ed25519 verification
106    /// (or were unsigned but presence-confirmed).
107    pub edges_verified: usize,
108    /// Number of edges that failed verification, with reasons.
109    pub edges_failed: usize,
110    /// Per-edge detail.
111    pub edges: Vec<EdgeResult>,
112    /// Maximum `reflection_depth` column value per namespace.
113    pub max_reflection_depth_per_namespace: HashMap<String, i32>,
114    /// `"within_cap"` when no namespace exceeded its governance cap,
115    /// `"exceeded_cap"` when at least one did, or
116    /// `"no_cap_configured"` when no governance policy rows exist.
117    pub bounded_status: String,
118    /// Optional `signed_events` entries when `--include-signed-events`.
119    #[serde(skip_serializing_if = "Vec::is_empty")]
120    pub signed_events: Vec<SignedEventSummary>,
121    /// RFC3339 timestamp of report generation.
122    pub generated_at: String,
123}
124
125// ─────────────────────────────────────────────────────────────────────
126// Helpers — package-private (pub(super) keeps the R7 surface clean)
127// ─────────────────────────────────────────────────────────────────────
128
129/// Encode bytes as a lowercase hexadecimal string. Used instead of the
130/// `hex` crate (which is not a direct dependency) to keep the
131/// dependency surface flat per repo convention.
132fn bytes_to_hex(bytes: &[u8]) -> String {
133    bytes.iter().map(|b| format!("{b:02x}")).collect()
134}
135
136/// Fetch a single memory's (id, namespace, reflection_depth) tuple.
137/// Returns `None` when not found.
138fn 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
153/// One `reflects_on` edge row as returned by the DB.
154struct 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
163/// Fetch all `reflects_on` edges whose `source_id = memory_id`,
164/// including the temporal-validity columns that are part of the
165/// signed bundle (H2 signs `valid_from` + `valid_until` alongside
166/// the other link fields — verification must re-derive the same
167/// canonical CBOR, so these must round-trip from the DB).
168fn 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
188/// Fetch up to 1000 `signed_events` rows whose `agent_id` matches any
189/// of the supplied memory ids (by convention the audit rows for a
190/// reflect use the agent_id field as the actor's identifier; the
191/// memory_id is embedded in the payload). Best-effort — returns empty
192/// on query failure.
193fn fetch_signed_events_for(conn: &Connection, ids: &[String]) -> Result<Vec<SignedEventSummary>> {
194    if ids.is_empty() {
195        return Ok(Vec::new());
196    }
197    // Build positional params manually — rusqlite's `params!` macro
198    // cannot splat a runtime-length slice, so we construct the SQL
199    // placeholder string ourselves and pass a slice of `&dyn ToSql`.
200    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            // col 3 = payload_hash (unused in summary)
222            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
231/// Look up the governance `max_reflection_depth` for a namespace.
232///
233/// Delegates to the existing [`crate::db::resolve_governance_policy`]
234/// chain-walker which reads `metadata.governance` from namespace
235/// standard memories (walking leaf-first up to the global `*`
236/// standard). Returns `None` when no policy with a
237/// `max_reflection_depth` exists anywhere in the chain.
238fn governance_cap_for_namespace(conn: &Connection, namespace: &str) -> Option<u32> {
239    // #880 — `max_reflection_depth` lives on `CorePolicy` after the
240    // governance decomposition; wire format unchanged.
241    crate::db::resolve_governance_policy(conn, namespace).and_then(|p| p.core.max_reflection_depth)
242}
243
244// ─────────────────────────────────────────────────────────────────────
245// Core chain-walk + verify logic
246// ─────────────────────────────────────────────────────────────────────
247
248/// Walk the `reflects_on` ancestry tree from `root_id`, verify every
249/// edge, and return the [`ChainReport`].
250///
251/// # Errors
252///
253/// Propagates database read errors.
254pub 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
262/// Variant of [`build_chain_report`] that lets the caller pin the
263/// `generated_at` timestamp. Used by `forensic::bundle` so the
264/// embedded `verification.json` is byte-stable across rebuilds (the
265/// bundle's own `manifest.generated_at` is the *only* legitimate
266/// non-deterministic field). `None` falls back to `Utc::now()`.
267///
268/// # Errors
269///
270/// Propagates database read errors.
271pub 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(&current_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        // Fetch memory meta to track namespace depths and check cap.
304        if let Some((_id, ns, rd)) = fetch_memory_meta(conn, &current_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        // Walk outbound `reflects_on` edges.
319        let out_edges = fetch_reflects_on_edges(conn, &current_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                &current_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    // v0.7.0 G-PHASE-E-4 (#709) — derive the top-line `ok` flag from
371    // the same predicate the exit code uses (`edges_failed == 0 &&
372    // bounded_status != "exceeded_cap"`). Kept here so callers reading
373    // the JSON wire shape don't have to re-derive it.
374    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
390/// Attempt to verify a single `reflects_on` edge's Ed25519 signature.
391///
392/// Returns `(verified, failure_reason, signature_hex)`. An unsigned
393/// edge (no signature blob) is always considered "verified" — absence
394/// of a signature is not a failure; it means the edge was written
395/// before attestation was enabled.
396/// Verify a single `reflects_on` edge's Ed25519 signature.
397///
398/// `valid_from` and `valid_until` must be the raw values stored in
399/// `memory_links` — they are part of the signed canonical CBOR bundle
400/// (H2 commits to all six `SignableLink` fields at sign time). Passing
401/// the wrong values causes the re-derived payload to diverge from what
402/// the signer committed to, which makes Ed25519 reject the signature
403/// even for an otherwise honest edge.
404///
405/// Returns `(verified, failure_reason, signature_hex)`.
406fn 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    // Unsigned edge — presence-confirmed; no signature to verify.
418    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
469// ─────────────────────────────────────────────────────────────────────
470// Text renderer
471// ─────────────────────────────────────────────────────────────────────
472
473pub(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
533// ─────────────────────────────────────────────────────────────────────
534// Entry point called by daemon_runtime dispatch
535// ─────────────────────────────────────────────────────────────────────
536
537/// Run the `verify-reflection-chain` subcommand against the SQLite DB at
538/// `db_path`. Returns an exit code: `0` if the chain is intact, `2`
539/// otherwise.
540///
541/// v0.7.0 G-PHASE-E-4 (#709) — raised the failure exit code from `1`
542/// to `2`. The previous `1` was indistinguishable from CLI argument
543/// errors / unwrap panics under shell error trapping; `2` is the
544/// conventional "verification failed" code (matches the convention
545/// raised on `verify-forensic-bundle` in the same fold) and aligns
546/// with the new top-line `ok` field in [`ChainReport`].
547///
548/// # Errors
549///
550/// Propagates I/O or database errors via `anyhow`.
551pub 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    // Exit code mirrors `report.ok`. The predicate is
565    // `edges_failed == 0 && bounded_status != "exceeded_cap"`, which is
566    // already cached on `report.ok` at construction time.
567    if report.ok { Ok(0) } else { Ok(2) }
568}
569
570// ─────────────────────────────────────────────────────────────────────
571// Unit tests
572// ─────────────────────────────────────────────────────────────────────
573
574#[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    /// Attach a `max_reflection_depth` governance policy to `ns` by
621    /// inserting a namespace standard memory (the same mechanism the
622    /// production path uses — see `resolve_governance_policy`).
623    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        // Unsigned edges count as verified.
687        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); // depth 1 > cap 0
697        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        // Serialise the `AI_MEMORY_KEY_DIR` mutation on the crate-global env
707        // lock so it cannot race keypair/cli::store/governance tests that
708        // also stomp this process-wide var. #626 Layer-3.
709        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; // tamper
733
734        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        // Point key lookup at the test key dir.
744        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        // With flag=false the vec is always empty.
766        let r = build_chain_report(&conn, &id, false).expect("report");
767        assert!(r.signed_events.is_empty());
768
769        // With flag=true it may still be empty (no events in this DB),
770        // but the call must not error.
771        let r2 = build_chain_report(&conn, &id, true).expect("report-se");
772        let _ = r2.signed_events; // just assert it's accessible
773    }
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    // -----------------------------------------------------------------
782    // C-3 coverage uplift — drive the remaining branches:
783    //   - fetch_memory_meta None branch (line 139)
784    //   - fetch_signed_events_for empty-input early-return (line 185)
785    //   - fetch_signed_events_for happy path with seeded rows (208-216)
786    //   - verify_edge: observed_by None / empty / unknown agent (405-433)
787    //   - render_text branches: max_depth table, edge reasons, signed
788    //     events footer (464+)
789    //   - run() JSON-format dispatch (528-541)
790    //   - run() exit-code-1 path
791    // -----------------------------------------------------------------
792
793    #[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        // Drives the row-decode block at lines 206-216 by pre-seeding a
812        // signed_events row with the same agent_id used in the IN clause.
813        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        // Force `lookup_peer_public_key` to return None by pointing the
883        // key dir at a fresh empty tempdir.
884        // Serialise on the crate-global env lock so the `AI_MEMORY_KEY_DIR`
885        // mutation cannot race sibling tests that share this var. #626 Layer-3.
886        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        // SAFETY: this test mutates a process-wide env var; the helper
891        // chain assumes no concurrent test relies on the previous value
892        // during this assertion.
893        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        // Build a synthetic report so we hit:
917        //   - the namespace table (lines 469-475)
918        //   - the failure_reason write (line 488)
919        //   - the signed_events footer (lines 495-511)
920        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        // Drives the `if ev.signature_present` else branch (line 508).
968        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        // Drives the JSON branch at lines 532-534.
999        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        // Re-open DB through `run` which uses crate::db::open.
1004        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        // The memory above was inserted into a DIFFERENT db (open_test_db
1013        // is called twice creating different temp paths). Re-init using
1014        // the real run path with an empty DB — exit code is 0 for an
1015        // empty/unknown id (single memory, no edges).
1016        let _ = run(&db_path, &args, &mut out);
1017        // We don't assert on the body here — the goal is to drive the
1018        // dispatch / json-render path itself.
1019    }
1020
1021    #[test]
1022    fn run_against_real_db_emits_text_report_and_exit_0() {
1023        // Full happy-path through `run`: open db, build report, render
1024        // text, exit 0 (drives lines 526-545 sans json branch).
1025        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 our local connection so `run`'s db::open can take a
1031        // fresh WAL handle.
1032        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        // Drives the cap-exceeded -> exit 1 arm at line 540.
1052        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        // v0.7.0 G-PHASE-E-4 (#709) — failure exit code raised from 1 to 2.
1070        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        // Drives the include_signed_events true branch through run's
1076        // JSON output, hitting the serialiser on the empty Vec path.
1077        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        // The JSON should at minimum carry the report wrapper fields.
1094        assert!(s.contains("\"root_id\""), "got: {s}");
1095        assert!(s.contains("\"bounded_status\""), "got: {s}");
1096    }
1097}