use crate::core::broker::DbBroker;
use crate::core::error;
use crate::core::external_action::{self, ExternalCapability};
use crate::core::state_commit;
use crate::core::store::Store;
use crate::core::todo;
use crate::plugins::federation;
use clap::{Parser, Subcommand};
use fancy_regex::Regex;
use rusqlite::OptionalExtension;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
#[derive(Parser, Debug)]
#[clap(name = "verify", about = "Replay verification proofs and detect drift")]
pub struct VerifyCli {
/// Output machine-readable JSON.
#[clap(long, global = true)]
json: bool,
/// List stale items only; do not run verification.
#[clap(long, global = true)]
stale: bool,
#[clap(subcommand)]
command: Option<VerifyCommand>,
}
#[derive(Subcommand, Debug)]
pub enum VerifyCommand {
/// Verify a single TODO.
Todo {
#[clap(value_name = "ID")]
id: String,
},
}
#[derive(Debug)]
struct VerifyTarget {
todo_id: String,
status: String,
proof_plan: Option<String>,
artifacts: Option<String>,
last_verified_at: Option<String>,
verification_policy_days: i64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct VerificationArtifacts {
completed_at: String,
proof_plan_results: Vec<ProofPlanResult>,
file_artifacts: Vec<FileArtifact>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct ProofPlanResult {
proof_gate: String,
status: String,
command: String,
output_hash: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct FileArtifact {
path: String,
hash: String,
size: u64,
mtime: Option<u64>,
}
#[derive(Debug, Serialize)]
struct VerifySummary {
total: usize,
passed: usize,
failed: usize,
unknown: usize,
stale: usize,
}
#[derive(Debug, Serialize)]
struct ProofCheckResult {
gate: String,
status: String,
expected_output_hash: Option<String>,
actual_output_hash: Option<String>,
reason: Option<String>,
}
#[derive(Debug, Serialize)]
struct ArtifactCheckResult {
path: String,
status: String,
expected_hash: Option<String>,
actual_hash: Option<String>,
reason: Option<String>,
}
#[derive(Debug, Serialize)]
struct VerifyTodoResult {
todo_id: String,
status: String,
proofs: Vec<ProofCheckResult>,
artifacts: Vec<ArtifactCheckResult>,
notes: Vec<String>,
}
#[derive(Debug, Serialize)]
struct VerifyReport {
verified_at: String,
summary: VerifySummary,
results: Vec<VerifyTodoResult>,
}
#[derive(Debug, Serialize)]
struct StaleItem {
todo_id: String,
last_verified_at: Option<String>,
verification_policy_days: i64,
}
fn now_iso() -> String {
crate::core::time::now_epoch_z()
}
fn epoch_secs(ts: &str) -> Option<i64> {
ts.trim_end_matches('Z').parse::<i64>().ok()
}
fn normalize_validate_output(raw: &str) -> String {
let ansi = Regex::new(r"\x1B\[[0-9;]*[A-Za-z]").expect("valid ANSI regex");
let elapsed_re = Regex::new(r" elapsed=\S+").expect("valid elapsed regex");
let stripped = ansi.replace_all(raw, "");
stripped
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| {
if line.contains("decapod_validate_user_") || line.contains("decapod_validate_repo_") {
"<tmp_validate_path>".to_string()
} else {
// Strip non-deterministic elapsed timing from summary line
elapsed_re.replace_all(line, "").to_string()
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn normalize_json_value(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut normalized = serde_json::Map::new();
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
for key in keys {
if key == "elapsed_ms" {
normalized.insert(
key.clone(),
serde_json::Value::Number(serde_json::Number::from(0)),
);
} else if key == "failures" || key == "warnings" || key == "self_heal" {
if let Some(arr) = map[key].as_array() {
let mut sorted_arr = arr.clone();
sorted_arr.sort_by_key(|a| a.to_string());
normalized.insert(
key.clone(),
serde_json::Value::Array(
sorted_arr.iter().map(normalize_json_value).collect(),
),
);
} else {
normalized.insert(key.clone(), normalize_json_value(&map[key]));
}
} else if key == "gate_timings" {
if let Some(arr) = map[key].as_array() {
let mut sorted_arr = arr.clone();
sorted_arr.sort_by(|a, b| {
let a_name = a.get("name").and_then(|v| v.as_str()).unwrap_or("");
let b_name = b.get("name").and_then(|v| v.as_str()).unwrap_or("");
a_name.cmp(b_name)
});
normalized.insert(
key.clone(),
serde_json::Value::Array(
sorted_arr.iter().map(normalize_json_value).collect(),
),
);
} else {
normalized.insert(key.clone(), normalize_json_value(&map[key]));
}
} else {
normalized.insert(key.clone(), normalize_json_value(&map[key]));
}
}
serde_json::Value::Object(normalized)
}
serde_json::Value::Array(items) => {
serde_json::Value::Array(items.iter().map(normalize_json_value).collect())
}
_ => value.clone(),
}
}
fn sha256_hex(input: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(input);
format!("sha256:{:x}", hasher.finalize())
}
fn hash_file(path: &Path) -> Result<(String, u64, Option<u64>), error::DecapodError> {
let bytes = fs::read(path)?;
let meta = fs::metadata(path)?;
let mtime = meta
.modified()
.ok()
.and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs());
Ok((sha256_hex(&bytes), meta.len(), mtime))
}
fn run_validate_and_hash(
store_root: &Path,
repo_root: &Path,
todo_id: Option<&str>,
) -> Result<(bool, String), error::DecapodError> {
if let Some(id) = todo_id {
// SAFETY: Setting env var is safe here because:
// 1. This is a CLI tool that runs single-threaded by default
// 2. We immediately scope the env var to the validate call below
// 3. We remove the var right after the call, ensuring no leakage
// 4. No concurrent threads are spawned that might race on this env var
unsafe {
std::env::set_var("DECAPOD_VERIFYING_TODO", id);
}
}
let exe = std::env::current_exe()?;
let exe_str = exe.to_string_lossy().to_string();
let output = external_action::execute(
store_root,
ExternalCapability::VerificationExec,
"verify.validate_passes",
&exe_str,
&["validate", "--format", "json"],
repo_root,
);
if todo_id.is_some() {
// SAFETY: Removing env var is safe here because:
// 1. We only remove the var we set above
// 2. No other part of the code depends on this var during validation
// 3. This restores the environment to its original state
unsafe {
std::env::remove_var("DECAPOD_VERIFYING_TODO");
}
}
let output = output?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&stdout) {
let normalized = normalize_json_value(&value);
let canonical = serde_json::to_string(&normalized).unwrap();
return Ok((output.status.success(), sha256_hex(canonical.as_bytes())));
}
let mut merged = stdout;
if !output.stderr.is_empty() {
merged.push('\n');
merged.push_str(&String::from_utf8_lossy(&output.stderr));
}
let normalized = normalize_validate_output(&merged);
Ok((output.status.success(), sha256_hex(normalized.as_bytes())))
}
fn verification_events_path(store: &Store) -> PathBuf {
store.root.join("verification_events.jsonl")
}
fn append_jsonl(path: &Path, value: &serde_json::Value) -> Result<(), error::DecapodError> {
let mut f = OpenOptions::new().create(true).append(true).open(path)?;
writeln!(f, "{}", serde_json::to_string(value).unwrap())?;
Ok(())
}
fn mirror_verification_to_federation(
store: &Store,
todo_id: &str,
title: &str,
body: &str,
tags: &str,
) {
let source = format!("event:{todo_id}");
let anchor = federation::find_node_by_source(store, &source)
.ok()
.flatten();
if let Ok(node) = federation::add_node(
store,
title,
"decision",
"notable",
"agent_inferred",
body,
&source,
tags,
"repo",
None,
"decapod",
) && let Some(intent_or_commitment) = anchor
{
let _ = federation::add_edge(store, &intent_or_commitment, &node.id, "depends_on");
}
let _ = federation::refresh_derived_files(store);
}
fn load_targets(
store: &Store,
single_id: Option<&str>,
) -> Result<Vec<VerifyTarget>, error::DecapodError> {
todo::initialize_todo_db(&store.root)?;
let broker = DbBroker::new(&store.root);
let db_path = todo::todo_db_path(&store.root);
broker.with_conn(&db_path, "decapod", None, "verify.targets", |conn| {
let mut out = Vec::new();
if let Some(id) = single_id {
let mut stmt = conn.prepare(
"SELECT t.id, t.status, v.proof_plan, v.verification_artifacts, v.last_verified_at, COALESCE(v.verification_policy_days, 90)\n FROM tasks t\n LEFT JOIN task_verification v ON v.todo_id = t.id\n WHERE t.id = ?1",
)?;
let rows = stmt.query_map(rusqlite::params![id], |row| {
Ok(VerifyTarget {
todo_id: row.get(0)?,
status: row.get(1)?,
proof_plan: row.get(2)?,
artifacts: row.get(3)?,
last_verified_at: row.get(4)?,
verification_policy_days: row.get(5)?,
})
})?;
for row in rows {
out.push(row?);
}
} else {
let mut stmt = conn.prepare(
"SELECT t.id, t.status, v.proof_plan, v.verification_artifacts, v.last_verified_at, COALESCE(v.verification_policy_days, 90)\n FROM tasks t\n LEFT JOIN task_verification v ON v.todo_id = t.id\n WHERE t.status = 'done'\n AND v.proof_plan IS NOT NULL\n AND v.proof_plan <> ''\n ORDER BY t.updated_at DESC",
)?;
let rows = stmt.query_map([], |row| {
Ok(VerifyTarget {
todo_id: row.get(0)?,
status: row.get(1)?,
proof_plan: row.get(2)?,
artifacts: row.get(3)?,
last_verified_at: row.get(4)?,
verification_policy_days: row.get(5)?,
})
})?;
for row in rows {
out.push(row?);
}
}
Ok(out)
})
}
fn persist_result(
store: &Store,
todo_id: &str,
status: &str,
notes: &str,
) -> Result<(), error::DecapodError> {
let ts = now_iso();
let broker = DbBroker::new(&store.root);
let db_path = todo::todo_db_path(&store.root);
let (proof_plan, verification_artifacts, verification_policy_days) =
broker.with_conn(&db_path, "decapod", None, "verify.persist", |conn| {
let existing: Option<(String, Option<String>, i64)> = conn
.query_row(
"SELECT proof_plan, verification_artifacts, verification_policy_days
FROM task_verification
WHERE todo_id = ?1",
rusqlite::params![todo_id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.optional()?;
let (proof_plan, verification_artifacts, verification_policy_days) =
existing.unwrap_or_else(|| ("[]".to_string(), None, 90));
conn.execute(
"INSERT INTO task_verification(todo_id, proof_plan, verification_artifacts, last_verified_at, last_verified_status, last_verified_notes, verification_policy_days, updated_at)
VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?4)
ON CONFLICT(todo_id) DO UPDATE SET
proof_plan=excluded.proof_plan,
verification_artifacts=excluded.verification_artifacts,
last_verified_at=excluded.last_verified_at,
last_verified_status=excluded.last_verified_status,
last_verified_notes=excluded.last_verified_notes,
verification_policy_days=excluded.verification_policy_days,
updated_at=excluded.updated_at",
rusqlite::params![
todo_id,
proof_plan,
verification_artifacts,
ts,
status,
notes,
verification_policy_days,
],
)?;
Ok((proof_plan, verification_artifacts, verification_policy_days))
})?;
todo::record_task_event(
&store.root,
"task.verify.result",
Some(todo_id),
serde_json::json!({
"proof_plan": serde_json::from_str::<serde_json::Value>(&proof_plan).unwrap_or_else(|_| serde_json::json!([])),
"verification_artifacts": verification_artifacts
.as_ref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.unwrap_or(serde_json::Value::Null),
"last_verified_status": status,
"last_verified_notes": notes,
"verification_policy_days": verification_policy_days
}),
)?;
mirror_verification_to_federation(
store,
todo_id,
&format!("Verification Result: {todo_id}"),
&format!("Verification status={status} notes={notes}"),
"proof,verification,result",
);
Ok(())
}
fn is_stale(target: &VerifyTarget, now_secs: i64) -> bool {
match target.last_verified_at.as_deref().and_then(epoch_secs) {
None => true,
Some(last) => now_secs.saturating_sub(last) > target.verification_policy_days * 86_400,
}
}
fn resolve_artifact_path(repo_root: &Path, stored: &str) -> PathBuf {
let path = Path::new(stored);
if path.is_absolute() {
path.to_path_buf()
} else {
repo_root.join(path)
}
}
fn verify_target(
target: &VerifyTarget,
store_root: &Path,
repo_root: &Path,
) -> Result<VerifyTodoResult, error::DecapodError> {
let mut result = VerifyTodoResult {
todo_id: target.todo_id.clone(),
status: "pass".to_string(),
proofs: Vec::new(),
artifacts: Vec::new(),
notes: Vec::new(),
};
if target.status != "done" {
result.status = "unknown".to_string();
result
.notes
.push("TODO is not in done state; only done tasks are verifiable".to_string());
return Ok(result);
}
let plan_raw = match target.proof_plan.as_deref() {
Some(v) if !v.trim().is_empty() => v,
_ => {
result.status = "unknown".to_string();
result.notes.push(
"Missing verification metadata. Remediation: mark task done with `--validated` or capture verification artifacts for this TODO.".to_string(),
);
return Ok(result);
}
};
let proof_plan: Vec<String> = match serde_json::from_str(plan_raw) {
Ok(v) => v,
Err(_) => {
result.status = "unknown".to_string();
result.notes.push(
"Invalid proof_plan format. Remediation: recapture verification artifacts for this TODO.".to_string(),
);
return Ok(result);
}
};
let supported_proofs = ["validate_passes", "state_commit"];
if proof_plan
.iter()
.any(|p| !supported_proofs.iter().any(|sp| p == *sp))
{
result.status = "unknown".to_string();
result.notes.push(
"Unsupported proof_plan. Supported: validate_passes, state_commit. Remediation: set proof_plan to [\"validate_passes\"] or [\"state_commit\"].".to_string(),
);
return Ok(result);
}
// Check validate_passes if in plan
let validate_check_needed = proof_plan.iter().any(|p| p == "validate_passes");
let artifacts_raw = match target.artifacts.as_deref() {
Some(v) if !v.trim().is_empty() => v,
_ => {
result.status = "unknown".to_string();
result.notes.push(
"Missing verification_artifacts. Remediation: capture verification artifacts for this TODO.".to_string(),
);
return Ok(result);
}
};
let artifacts: VerificationArtifacts = match serde_json::from_str(artifacts_raw) {
Ok(v) => v,
Err(_) => {
result.status = "unknown".to_string();
result.notes.push(
"Malformed verification_artifacts JSON. Remediation: recapture verification artifacts for this TODO.".to_string(),
);
return Ok(result);
}
};
let expected_proof = artifacts
.proof_plan_results
.iter()
.find(|p| p.proof_gate == "validate_passes");
let expected_hash = expected_proof.map(|p| p.output_hash.clone());
if expected_hash.is_none() {
result.status = "unknown".to_string();
result.notes.push(
"Missing baseline validate_passes output hash. Remediation: capture verification artifacts for this TODO.".to_string(),
);
return Ok(result);
}
// Only check validate_passes if it's in the proof plan
if validate_check_needed {
let (validate_ok, actual_hash) =
run_validate_and_hash(store_root, repo_root, Some(&target.todo_id))?;
let expected = expected_hash.unwrap_or_default();
if !validate_ok {
result.status = "fail".to_string();
result.proofs.push(ProofCheckResult {
gate: "validate_passes".to_string(),
status: "fail".to_string(),
expected_output_hash: Some(expected),
actual_output_hash: Some(actual_hash),
reason: Some("decapod validate did not pass".to_string()),
});
} else if actual_hash != expected {
result.status = "fail".to_string();
result.proofs.push(ProofCheckResult {
gate: "validate_passes".to_string(),
status: "fail".to_string(),
expected_output_hash: Some(expected),
actual_output_hash: Some(actual_hash),
reason: Some("validate output hash changed".to_string()),
});
} else {
result.proofs.push(ProofCheckResult {
gate: "validate_passes".to_string(),
status: "pass".to_string(),
expected_output_hash: Some(expected),
actual_output_hash: Some(actual_hash),
reason: None,
});
}
}
// Check state_commit if in plan
if proof_plan.iter().any(|p| p == "state_commit") {
let state_commit_proof = artifacts
.proof_plan_results
.iter()
.find(|p| p.proof_gate == "state_commit");
let expected_root = state_commit_proof.map(|p| p.output_hash.clone());
let expected = match expected_root {
Some(exp) => exp,
None => {
result.status = "fail".to_string();
result.notes.push(
"Missing baseline state_commit output hash. Remediation: run `decapod state-commit prove --base <sha> --head <sha> --output scope_record.cbor` and capture as proof artifact.".to_string(),
);
String::new() // Skip the rest of state_commit verification
}
};
// Only verify if we have an expected_root
if !expected.is_empty() {
// Full state_commit verification using canonical prove logic:
// 1. scope_record.cbor must exist
// 2. Recompute Merkle root from git objects at current HEAD
// 3. Compare recomputed root to expected_root
let scope_record_path = repo_root.join("scope_record.cbor");
if !scope_record_path.exists() {
result.status = "fail".to_string();
result.proofs.push(ProofCheckResult {
gate: "state_commit".to_string(),
status: "fail".to_string(),
expected_output_hash: Some(expected.clone()),
actual_output_hash: None,
reason: Some("scope_record.cbor not found in repo root. Run `decapod state-commit prove` first.".to_string()),
});
} else {
// Get current HEAD
let head_output = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo_root)
.output();
// Use library function to recompute root from git objects (hermetic, no subprocess)
// This verifies the full contract: git objects -> entries -> Merkle root
let current_head = match head_output {
Ok(o) if o.status.success() => {
String::from_utf8_lossy(&o.stdout).trim().to_string()
}
_ => String::new(),
};
let recomputed_root = if !current_head.is_empty() {
// Get base_sha (previous commit)
let base_output = std::process::Command::new("git")
.args(["rev-parse", "HEAD~1"])
.current_dir(repo_root)
.output();
let base_sha = match base_output {
Ok(o) if o.status.success() => {
String::from_utf8_lossy(&o.stdout).trim().to_string()
}
_ => String::new(),
};
if !base_sha.is_empty() {
let input = state_commit::StateCommitInput {
base_sha,
head_sha: current_head.clone(),
ignore_policy_hash: "da39a3ee5e6b4b0d3255bfef95601890afd80709"
.to_string(),
};
match state_commit::prove(&input, repo_root) {
Ok(result) => Some(result.state_commit_root),
Err(_) => None,
}
} else {
None
}
} else {
None
};
match recomputed_root {
Some(root) if root == expected => {
// Verify HEAD binding - check scope_record contains current HEAD
let scope_bytes = std::fs::read(&scope_record_path).unwrap_or_default();
let head_in_record = !current_head.is_empty()
&& scope_bytes
.windows(current_head.len())
.any(|w| w == current_head.as_bytes());
if !head_in_record {
result.status = "fail".to_string();
result.proofs.push(ProofCheckResult {
gate: "state_commit".to_string(),
status: "fail".to_string(),
expected_output_hash: Some(expected.clone()),
actual_output_hash: Some(root.clone()),
reason: Some(format!("STATE_COMMIT head_sha mismatch. Current HEAD: {current_head} not in scope_record. Run `decapod state-commit prove` to regenerate.")),
});
} else {
result.proofs.push(ProofCheckResult {
gate: "state_commit".to_string(),
status: "pass".to_string(),
expected_output_hash: Some(expected.clone()),
actual_output_hash: Some(root),
reason: Some("STATE_COMMIT verified: root recomputed from git objects matches expected, bound to current HEAD".to_string()),
});
}
}
Some(root) => {
result.status = "fail".to_string();
result.proofs.push(ProofCheckResult {
gate: "state_commit".to_string(),
status: "fail".to_string(),
expected_output_hash: Some(expected.clone()),
actual_output_hash: Some(root.clone()),
reason: Some(format!("STATE_COMMIT root mismatch. Expected: {expected}, Recomputed: {root}. Files changed since scope recorded. Run `decapod state-commit prove` to regenerate.")),
});
}
None => {
result.status = "fail".to_string();
result.proofs.push(ProofCheckResult {
gate: "state_commit".to_string(),
status: "fail".to_string(),
expected_output_hash: Some(expected.clone()),
actual_output_hash: None,
reason: Some("Failed to recompute STATE_COMMIT root. Verify decapod binary is available.".to_string()),
});
}
}
}
}
}
if artifacts.file_artifacts.is_empty() {
result.status = "unknown".to_string();
result.notes.push(
"Missing file_artifacts. Remediation: capture file hash artifacts (for MVP include AGENTS.md)."
.to_string(),
);
return Ok(result);
}
for expected in artifacts.file_artifacts {
let disk_path = resolve_artifact_path(repo_root, &expected.path);
if !disk_path.exists() {
result.status = "fail".to_string();
result.artifacts.push(ArtifactCheckResult {
path: expected.path,
status: "fail".to_string(),
expected_hash: Some(expected.hash),
actual_hash: Some("<missing>".to_string()),
reason: Some("artifact missing".to_string()),
});
continue;
}
let (actual_hash, _, _) = hash_file(&disk_path)?;
if actual_hash != expected.hash {
result.status = "fail".to_string();
result.artifacts.push(ArtifactCheckResult {
path: expected.path,
status: "fail".to_string(),
expected_hash: Some(expected.hash),
actual_hash: Some(actual_hash),
reason: Some("hash mismatch".to_string()),
});
} else {
result.artifacts.push(ArtifactCheckResult {
path: expected.path,
status: "pass".to_string(),
expected_hash: Some(expected.hash),
actual_hash: Some(actual_hash),
reason: None,
});
}
}
Ok(result)
}
pub fn capture_baseline_for_todo(
store: &Store,
repo_root: &Path,
todo_id: &str,
artifact_paths: Vec<String>,
) -> Result<(), error::DecapodError> {
todo::initialize_todo_db(&store.root)?;
let broker = DbBroker::new(&store.root);
let db_path = todo::todo_db_path(&store.root);
let status: Option<String> =
broker.with_conn(&db_path, "decapod", None, "verify.capture.read", |conn| {
let status = conn
.query_row(
"SELECT status FROM tasks WHERE id = ?1",
rusqlite::params![todo_id],
|row| row.get(0),
)
.optional()?;
Ok(status)
})?;
let Some(task_status) = status else {
return Err(error::DecapodError::NotFound(format!(
"TODO not found: {todo_id}"
)));
};
if task_status != "done" {
return Err(error::DecapodError::ValidationError(
"Task must be in done state before capturing verification artifacts".to_string(),
));
}
let paths = if artifact_paths.is_empty() {
vec!["AGENTS.md".to_string()]
} else {
artifact_paths
};
let mut file_artifacts = Vec::new();
for path in paths {
let disk_path = resolve_artifact_path(repo_root, &path);
if !disk_path.exists() {
return Err(error::DecapodError::NotFound(format!(
"Verification artifact file not found: {}. `--artifact` expects a file path relative to the repo root, for example `--artifact README.md`. To attach notes, use `decapod todo comment --id {}`.",
disk_path.display(),
todo_id
)));
}
let (hash, size, mtime) = hash_file(&disk_path)?;
file_artifacts.push(FileArtifact {
path,
hash,
size,
mtime,
});
}
let (validate_ok, output_hash) = run_validate_and_hash(&store.root, repo_root, Some(todo_id))?;
let ts = now_iso();
let artifacts = VerificationArtifacts {
completed_at: ts.clone(),
proof_plan_results: vec![ProofPlanResult {
proof_gate: "validate_passes".to_string(),
status: if validate_ok {
"pass".to_string()
} else {
"fail".to_string()
},
command: "decapod validate".to_string(),
output_hash,
}],
file_artifacts,
};
let artifacts_json = serde_json::to_string(&artifacts).unwrap();
let proof_plan_json = serde_json::to_string(&vec!["validate_passes"]).unwrap();
let baseline_status = if validate_ok { "pass" } else { "fail" };
let baseline_notes = if validate_ok {
"baseline captured"
} else {
"baseline captured while validate was failing"
};
broker.with_conn(&db_path, "decapod", None, "verify.capture.write", |conn| {
conn.execute(
"INSERT INTO task_verification(todo_id, proof_plan, verification_artifacts, last_verified_at, last_verified_status, last_verified_notes, verification_policy_days, updated_at)\n VALUES(?1, ?2, ?3, ?4, ?5, ?6, 90, ?4)\n ON CONFLICT(todo_id) DO UPDATE SET\n proof_plan=excluded.proof_plan,\n verification_artifacts=excluded.verification_artifacts,\n last_verified_at=excluded.last_verified_at,\n last_verified_status=excluded.last_verified_status,\n last_verified_notes=excluded.last_verified_notes,\n verification_policy_days=excluded.verification_policy_days,\n updated_at=excluded.updated_at",
rusqlite::params![
todo_id,
proof_plan_json,
artifacts_json,
ts,
baseline_status,
baseline_notes
],
)?;
Ok(())
})?;
todo::record_task_event(
&store.root,
"task.verify.capture",
Some(todo_id),
serde_json::json!({
"proof_plan": ["validate_passes"],
"verification_artifacts": artifacts,
"last_verified_status": baseline_status,
"last_verified_notes": baseline_notes,
"verification_policy_days": 90
}),
)?;
Ok(())
}
pub fn run_verify_cli(
store: &Store,
repo_root: &Path,
cli: VerifyCli,
) -> Result<(), error::DecapodError> {
let single_id = cli
.command
.as_ref()
.map(|VerifyCommand::Todo { id }| id.as_str());
let targets = load_targets(store, single_id)?;
if single_id.is_some() && targets.is_empty() {
return Err(error::DecapodError::NotFound("TODO not found".to_string()));
}
let now = now_iso();
let now_secs = epoch_secs(&now).unwrap_or(0);
if cli.stale {
let stale_items: Vec<StaleItem> = targets
.iter()
.filter(|t| t.status == "done" && is_stale(t, now_secs))
.map(|t| StaleItem {
todo_id: t.todo_id.clone(),
last_verified_at: t.last_verified_at.clone(),
verification_policy_days: t.verification_policy_days,
})
.collect();
if cli.json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"checked_at": now,
"stale": stale_items
}))
.unwrap()
);
} else if stale_items.is_empty() {
println!("No stale TODOs found.");
} else {
println!("Stale TODOs:");
for item in stale_items {
println!(
"- {} (last_verified_at={}, policy_days={})",
item.todo_id,
item.last_verified_at.unwrap_or_else(|| "never".to_string()),
item.verification_policy_days
);
}
}
return Ok(());
}
let run_id = crate::core::ulid::new_ulid();
let mut results = Vec::new();
for target in &targets {
let result = verify_target(target, &store.root, repo_root)?;
persist_result(
store,
&result.todo_id,
&result.status,
&result.notes.join("; "),
)?;
append_jsonl(
&verification_events_path(store),
&serde_json::json!({
"event_type": "verification.todo_result",
"ts": now,
"run_id": run_id,
"todo_id": result.todo_id,
"status": result.status,
"proofs": result.proofs,
"artifacts": result.artifacts,
"notes": result.notes,
}),
)?;
results.push(result);
}
let summary = VerifySummary {
total: results.len(),
passed: results.iter().filter(|r| r.status == "pass").count(),
failed: results.iter().filter(|r| r.status == "fail").count(),
unknown: results.iter().filter(|r| r.status == "unknown").count(),
stale: targets.iter().filter(|t| is_stale(t, now_secs)).count(),
};
append_jsonl(
&verification_events_path(store),
&serde_json::json!({
"event_type": "verification.run",
"ts": now,
"run_id": run_id,
"summary": {
"total": summary.total,
"passed": summary.passed,
"failed": summary.failed,
"unknown": summary.unknown,
"stale": summary.stale,
}
}),
)?;
let report = VerifyReport {
verified_at: now,
summary,
results,
};
if cli.json {
println!("{}", serde_json::to_string_pretty(&report).unwrap());
} else {
println!("Verification report at {}", report.verified_at);
for r in &report.results {
println!("- {} [{}]", r.todo_id, r.status);
for p in &r.proofs {
if p.status == "fail" {
println!(
" proof {} failed (expected={}, actual={})",
p.gate,
p.expected_output_hash.as_deref().unwrap_or("n/a"),
p.actual_output_hash.as_deref().unwrap_or("n/a")
);
}
}
for a in &r.artifacts {
if a.status == "fail" {
println!(
" artifact {} failed (expected={}, actual={})",
a.path,
a.expected_hash.as_deref().unwrap_or("n/a"),
a.actual_hash.as_deref().unwrap_or("n/a")
);
}
}
for n in &r.notes {
println!(" note: {n}");
}
}
println!(
"Summary: total={} passed={} failed={} unknown={} stale={}",
report.summary.total,
report.summary.passed,
report.summary.failed,
report.summary.unknown,
report.summary.stale
);
}
if report.summary.failed > 0 {
return Err(error::DecapodError::ValidationError(format!(
"verification failed for {} TODO(s)",
report.summary.failed
)));
}
Ok(())
}