Skip to main content

ai_memory/cli/
verify_signed_events.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `ai-memory verify-signed-events-chain` — walk the SQL-side
5//! `signed_events` cross-row hash chain (v34, #698 V-4 closeout) and
6//! emit a structured chain-integrity report.
7//!
8//! Distinct from `verify-reflection-chain` (which walks the
9//! reflects_on edges in `memory_links`) and from `audit verify`
10//! (which walks the JSONL audit log under `<audit_dir>/audit.log`).
11//! Three complementary verifiers, three load-bearing properties:
12//!
13//! - `verify-signed-events-chain` (this surface): the SQL-side
14//!   cross-row hash chain on `signed_events`. Daemon-local
15//!   tamper-evidence; auditor reads it directly from the database.
16//! - `audit verify`: the on-disk JSONL chain. Portable evidence
17//!   format for handoff to a SIEM.
18//! - `verify-reflection-chain`: per-edge Ed25519 signatures on
19//!   `reflects_on` links. Reflection ancestry attestation.
20//!
21//! ## Exit codes
22//!
23//! - `0` — chain fully verified.
24//! - `1` — chain break detected (sequence gap, duplicate, or
25//!   `prev_hash` mismatch).
26//!
27//! ## Output formats
28//!
29//! - `--format text` (default) — one-line human report on stdout.
30//! - `--format json` — machine-parseable report mirroring the
31//!   [`crate::signed_events::ChainVerificationReport`] shape.
32
33use anyhow::{Context, Result};
34use serde::Serialize;
35use std::path::Path;
36
37use crate::cli::CliOutput;
38
39/// Shared `.context` label for the chain-report write paths (#1558 batch 6).
40const CTX_WRITE_CHAIN_REPORT: &str = "write chain report";
41
42/// Arguments for `ai-memory verify-signed-events-chain`.
43#[derive(clap::Args, Debug)]
44pub struct VerifySignedEventsChainArgs {
45    /// Lower-bound sequence (exclusive). Rows with
46    /// `sequence > since` are walked; rows at or below `since` are
47    /// trusted as previously-verified. Default 0 (walk every row).
48    #[arg(long, value_name = "SEQUENCE", default_value_t = 0)]
49    pub since: i64,
50
51    /// Output format: `text` (default) or `json`.
52    #[arg(long, value_name = "FORMAT", default_value = "text")]
53    pub format: String,
54}
55
56/// JSON-serialised mirror of
57/// [`crate::signed_events::ChainVerificationReport`]. We don't
58/// derive `Serialize` on the original because it lives in a
59/// non-CLI module; the CLI layer owns the wire shape.
60#[derive(Debug, Serialize)]
61pub struct ChainVerifyReportJson {
62    pub rows_checked: u64,
63    pub chain_break: Option<i64>,
64    pub signature_failures: Vec<i64>,
65    pub chain_holds: bool,
66}
67
68/// Run the verifier. Returns the desired process exit code (0 on
69/// chain GREEN, 1 on chain break).
70///
71/// # Errors
72///
73/// Returns the underlying `rusqlite` or formatter error if the SQL
74/// query or the report rendering fails.
75pub fn run(
76    db_path: &Path,
77    args: &VerifySignedEventsChainArgs,
78    out: &mut CliOutput<'_>,
79) -> Result<i32> {
80    let conn =
81        crate::db::open(db_path).with_context(|| format!("open db at {}", db_path.display()))?;
82    let since = if args.since > 0 {
83        Some(args.since)
84    } else {
85        None
86    };
87    let report = crate::signed_events::verify_chain(&conn, since)
88        .context("verify_chain over signed_events")?;
89    let holds = report.chain_holds();
90
91    match args.format.as_str() {
92        "json" => {
93            let wire = ChainVerifyReportJson {
94                rows_checked: report.rows_checked,
95                chain_break: report.chain_break,
96                signature_failures: report.signature_failures.clone(),
97                chain_holds: holds,
98            };
99            let json = serde_json::to_string_pretty(&wire).context("serialize chain report")?;
100            writeln!(out.stdout, "{json}").context(CTX_WRITE_CHAIN_REPORT)?;
101        }
102        _ => {
103            // text — one-line summary on stdout.
104            if holds {
105                writeln!(
106                    out.stdout,
107                    "verify-signed-events-chain OK: {} row(s) walked, chain holds",
108                    report.rows_checked,
109                )
110                .context(CTX_WRITE_CHAIN_REPORT)?;
111            } else {
112                let where_ = report
113                    .chain_break
114                    .map_or_else(|| "<unknown>".to_string(), |s| s.to_string());
115                writeln!(
116                    out.stdout,
117                    "verify-signed-events-chain FAIL: chain break at sequence={where_} \
118                     ({} row(s) walked)",
119                    report.rows_checked,
120                )
121                .context(CTX_WRITE_CHAIN_REPORT)?;
122            }
123        }
124    }
125
126    Ok(if holds { 0 } else { 1 })
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::signed_events::{SignedEvent, append_signed_event, payload_hash};
133
134    fn fixture_event(payload: &[u8]) -> SignedEvent {
135        SignedEvent {
136            id: uuid::Uuid::new_v4().to_string(),
137            agent_id: "alice".to_string(),
138            event_type: crate::signed_events::event_types::MEMORY_LINK_CREATED.to_string(),
139            payload_hash: payload_hash(payload),
140            signature: None,
141            attest_level: "unsigned".to_string(),
142            timestamp: chrono::Utc::now().to_rfc3339(),
143            ..SignedEvent::default()
144        }
145    }
146
147    fn temp_db() -> (tempfile::TempDir, std::path::PathBuf) {
148        let dir = tempfile::Builder::new()
149            .prefix("verify-signed-events-")
150            .tempdir()
151            .expect("tempdir");
152        let path = dir.path().join("test.db");
153        drop(crate::db::open(&path).expect("init db"));
154        (dir, path)
155    }
156
157    #[test]
158    fn empty_db_reports_zero_rows_chain_holds() {
159        let (_dir, path) = temp_db();
160        let args = VerifySignedEventsChainArgs {
161            since: 0,
162            format: "json".to_string(),
163        };
164        let mut buf_out = Vec::<u8>::new();
165        let mut buf_err = Vec::<u8>::new();
166        let mut out = CliOutput::from_std(&mut buf_out, &mut buf_err);
167        let code = run(&path, &args, &mut out).expect("run");
168        assert_eq!(code, 0, "empty chain holds vacuously");
169        let s = String::from_utf8(buf_out).expect("utf-8");
170        assert!(s.contains("\"chain_holds\": true"), "got: {s}");
171        assert!(s.contains("\"rows_checked\": 0"), "got: {s}");
172    }
173
174    #[test]
175    fn populated_db_reports_chain_ok() {
176        let (_dir, path) = temp_db();
177        {
178            let conn = crate::db::open(&path).expect("open");
179            for i in 0..3 {
180                append_signed_event(&conn, &fixture_event(format!("payload-{i}").as_bytes()))
181                    .expect("append");
182            }
183        }
184        let args = VerifySignedEventsChainArgs {
185            since: 0,
186            format: "text".to_string(),
187        };
188        let mut buf_out = Vec::<u8>::new();
189        let mut buf_err = Vec::<u8>::new();
190        let mut out = CliOutput::from_std(&mut buf_out, &mut buf_err);
191        let code = run(&path, &args, &mut out).expect("run");
192        assert_eq!(code, 0, "3-row clean chain holds; got code={code}");
193        let s = String::from_utf8(buf_out).expect("utf-8");
194        assert!(s.contains("OK"), "got: {s}");
195        assert!(s.contains("3 row(s) walked"), "got: {s}");
196    }
197
198    // Note: The tampered-chain → exit-code-1 path is covered by the
199    // integration test `tests/signed_events_chain_v34.rs::
200    // tamper_in_middle_row_breaks_chain` (calling `verify_chain`
201    // directly) and is intentionally NOT duplicated here — exercising
202    // `UPDATE signed_events` from a `src/` file (even under `#[cfg(test)]`)
203    // would trip the `append_only_invariant_no_mutators_in_src`
204    // guard in `signed_events.rs`.
205
206    // ----------------------------------------------------------------
207    // C-3 coverage uplift — drive `since > 0` branch (line 80) and the
208    // FAIL render path (lines 109-118). We trigger a chain break with
209    // a raw INSERT that supplies a wrong sequence — pure INSERTs are
210    // not flagged by the append-only invariant guard which scans for
211    // UPDATE/DELETE only.
212    // ----------------------------------------------------------------
213
214    #[test]
215    fn since_filter_excludes_lower_sequences() {
216        // Drives the `since > 0` -> `Some(...)` arm at line 80.
217        let (_dir, path) = temp_db();
218        {
219            let conn = crate::db::open(&path).expect("open");
220            for i in 0..3 {
221                append_signed_event(&conn, &fixture_event(format!("p-{i}").as_bytes()))
222                    .expect("append");
223            }
224        }
225        let args = VerifySignedEventsChainArgs {
226            since: 1,
227            format: "json".to_string(),
228        };
229        let mut buf_out = Vec::<u8>::new();
230        let mut buf_err = Vec::<u8>::new();
231        let mut out = CliOutput::from_std(&mut buf_out, &mut buf_err);
232        let code = run(&path, &args, &mut out).expect("run");
233        assert_eq!(code, 0, "filtered chain still holds");
234        let s = String::from_utf8(buf_out).expect("utf-8");
235        // We skipped sequence=1, so 2 rows walked.
236        assert!(s.contains("\"rows_checked\": 2"), "got: {s}");
237    }
238
239    #[test]
240    fn broken_chain_text_format_reports_fail_with_sequence() {
241        // Drives lines 99-118: the text-format FAIL branch with a real
242        // chain break and the `where_` resolution from `chain_break`.
243        let (_dir, path) = temp_db();
244        {
245            let conn = crate::db::open(&path).expect("open");
246            // Seed two clean rows.
247            append_signed_event(&conn, &fixture_event(b"p-0")).expect("append-1");
248            append_signed_event(&conn, &fixture_event(b"p-1")).expect("append-2");
249            // Insert a tampered row that lies about its sequence to
250            // create a gap. INSERT only — does not trip the append-only
251            // mutator scan (UPDATE/DELETE).
252            conn.execute(
253                "INSERT INTO signed_events \
254                 (id, agent_id, event_type, payload_hash, signature, attest_level, \
255                  timestamp, prev_hash, sequence) \
256                 VALUES (?1, ?2, ?3, ?4, NULL, 'unsigned', ?5, X'00', 99)",
257                rusqlite::params![
258                    uuid::Uuid::new_v4().to_string(),
259                    "alice",
260                    "memory_link.created",
261                    payload_hash(b"p-99"),
262                    chrono::Utc::now().to_rfc3339(),
263                ],
264            )
265            .expect("raw INSERT tampered row");
266        }
267        let args = VerifySignedEventsChainArgs {
268            since: 0,
269            format: "text".to_string(),
270        };
271        let mut buf_out = Vec::<u8>::new();
272        let mut buf_err = Vec::<u8>::new();
273        let mut out = CliOutput::from_std(&mut buf_out, &mut buf_err);
274        let code = run(&path, &args, &mut out).expect("run");
275        assert_eq!(code, 1, "chain break must produce exit code 1");
276        let s = String::from_utf8(buf_out).expect("utf-8");
277        assert!(s.contains("FAIL"), "must say FAIL; got: {s}");
278        assert!(
279            s.contains("chain break at sequence="),
280            "must surface break; got: {s}"
281        );
282    }
283
284    #[test]
285    fn broken_chain_json_format_carries_chain_break() {
286        // Drives the JSON-format FAIL summary (the JSON arm
287        // independent of holds=true/false).
288        let (_dir, path) = temp_db();
289        {
290            let conn = crate::db::open(&path).expect("open");
291            append_signed_event(&conn, &fixture_event(b"p-0")).expect("append-1");
292            conn.execute(
293                "INSERT INTO signed_events \
294                 (id, agent_id, event_type, payload_hash, signature, attest_level, \
295                  timestamp, prev_hash, sequence) \
296                 VALUES (?1, ?2, ?3, ?4, NULL, 'unsigned', ?5, X'00', 42)",
297                rusqlite::params![
298                    uuid::Uuid::new_v4().to_string(),
299                    "alice",
300                    "memory_link.created",
301                    payload_hash(b"p-42"),
302                    chrono::Utc::now().to_rfc3339(),
303                ],
304            )
305            .expect("raw INSERT");
306        }
307        let args = VerifySignedEventsChainArgs {
308            since: 0,
309            format: "json".to_string(),
310        };
311        let mut buf_out = Vec::<u8>::new();
312        let mut buf_err = Vec::<u8>::new();
313        let mut out = CliOutput::from_std(&mut buf_out, &mut buf_err);
314        let code = run(&path, &args, &mut out).expect("run");
315        assert_eq!(code, 1);
316        let s = String::from_utf8(buf_out).expect("utf-8");
317        assert!(s.contains("\"chain_holds\": false"), "got: {s}");
318        // chain_break carries the offending sequence (one of 2 or 42).
319        assert!(s.contains("\"chain_break\":"), "got: {s}");
320    }
321
322    #[test]
323    fn default_format_falls_back_to_text() {
324        // The `_ =>` arm at line 99 fires for any non-`json` value —
325        // including an empty string, "yaml", or the actual default
326        // "text". Confirms the default-arm dispatch.
327        let (_dir, path) = temp_db();
328        let args = VerifySignedEventsChainArgs {
329            since: 0,
330            format: "yaml-unrecognised".to_string(),
331        };
332        let mut buf_out = Vec::<u8>::new();
333        let mut buf_err = Vec::<u8>::new();
334        let mut out = CliOutput::from_std(&mut buf_out, &mut buf_err);
335        let code = run(&path, &args, &mut out).expect("run");
336        assert_eq!(code, 0);
337        let s = String::from_utf8(buf_out).expect("utf-8");
338        assert!(s.contains("OK"), "must hit text branch; got: {s}");
339    }
340}