use crate::core::assets;
use crate::core::broker::DbBroker;
use crate::core::capsule_policy::{self, POLICY_SCHEMA_VERSION};
use crate::core::context_capsule::DeterministicContextCapsule;
use crate::core::error;
use crate::core::migration;
use crate::core::output;
use crate::core::plan_governance;
use crate::core::project_specs::{
LOCAL_PROJECT_SPECS, LOCAL_PROJECT_SPECS_ARCHITECTURE, LOCAL_PROJECT_SPECS_DIR,
LOCAL_PROJECT_SPECS_INTENT, LOCAL_PROJECT_SPECS_INTERFACES, LOCAL_PROJECT_SPECS_MANIFEST,
LOCAL_PROJECT_SPECS_MANIFEST_SCHEMA, LOCAL_PROJECT_SPECS_OPERATIONS,
LOCAL_PROJECT_SPECS_SECURITY, LOCAL_PROJECT_SPECS_SEMANTICS, LOCAL_PROJECT_SPECS_VALIDATION,
hash_text, read_specs_manifest, repo_signal_fingerprint,
};
use crate::core::scaffold::DECAPOD_GITIGNORE_RULES;
use crate::core::store::{Store, StoreKind};
use crate::core::workunit::{self, WorkUnitManifest, WorkUnitStatus};
use crate::plugins::aptitude::{SkillCard, SkillResolution};
use crate::plugins::internalize::{self, DeterminismClass, InternalizationManifest, ReplayClass};
use crate::{db, primitives, todo};
use fancy_regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{Duration, Instant};
fn is_inside_git_work_tree(repo_root: &Path) -> bool {
std::process::Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.current_dir(repo_root)
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
fn container_signal_reasons(repo_root: &Path) -> Vec<&'static str> {
[
(
std::env::var("DECAPOD_CONTAINER").ok().as_deref() == Some("1"),
"DECAPOD_CONTAINER=1",
),
(repo_root.join(".dockerenv").exists(), ".dockerenv marker"),
(
repo_root.join(".devcontainer").exists(),
".devcontainer marker",
),
(
std::env::var("DOCKER_CONTAINER").is_ok(),
"DOCKER_CONTAINER env",
),
]
.into_iter()
.filter_map(|(signal, name)| signal.then_some(name))
.collect()
}
fn is_container_workspaces_disabled(repo_root: &Path) -> bool {
let config_path = repo_root.join(".decapod").join("config.toml");
if !config_path.exists() {
return false;
}
match std::fs::read_to_string(config_path) {
Ok(content) => content.contains("container_workspaces = false"),
Err(_) => false,
}
}
fn auto_remediable_validation_message(code: &str, message: &str, agent_action: &str) -> String {
format!(
"AUTOREMEDIABLE_VALIDATION_ERROR code={} severity=transient auto_remediable=true audience=agent agent_action=\"{}\" user_note=\"Recoverable validation issue; the agent should take this action or report the concrete blocker.\"\n{}",
code, agent_action, message
)
}
macro_rules! gate {
($_scope:expr, $timings:expr, $ctx:expr, $name:literal, $body:expr) => {{
let start = Instant::now();
if let Err(e) = $body {
fail(&format!("gate error: {e}"), $ctx);
}
$timings.lock().unwrap().push(($name, start.elapsed()));
}};
}
struct ValidationContext {
pass_count: AtomicU32,
fail_count: AtomicU32,
warn_count: AtomicU32,
fails: Mutex<Vec<String>>,
warns: Mutex<Vec<String>>,
repo_files_cache: Mutex<Vec<(PathBuf, Vec<PathBuf>)>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ValidationGateTiming {
pub name: String,
pub elapsed_ms: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct ValidationReport {
pub status: String,
pub elapsed_ms: u64,
pub pass_count: u32,
pub fail_count: u32,
pub warn_count: u32,
pub failures: Vec<String>,
pub warnings: Vec<String>,
pub gate_timings: Vec<ValidationGateTiming>,
}
impl ValidationContext {
fn new() -> Self {
Self {
pass_count: AtomicU32::new(0),
fail_count: AtomicU32::new(0),
warn_count: AtomicU32::new(0),
fails: Mutex::new(Vec::new()),
warns: Mutex::new(Vec::new()),
repo_files_cache: Mutex::new(Vec::new()),
}
}
}
fn collect_repo_files(
root: &Path,
out: &mut Vec<PathBuf>,
ctx: &ValidationContext,
) -> Result<(), error::DecapodError> {
let cached = {
let cache = ctx.repo_files_cache.lock().unwrap();
cache
.iter()
.find(|(k, _)| k == root)
.map(|(_, v)| v.clone())
};
if let Some(files) = cached {
out.extend(files);
return Ok(());
}
fn recurse(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), error::DecapodError> {
if !dir.is_dir() {
return Ok(());
}
let name = dir.file_name().and_then(|s| s.to_str()).unwrap_or("");
if matches!(
name,
".git"
| "target"
| ".decapod"
| "artifacts"
| "node_modules"
| ".venv"
| ".mypy_cache"
| ".pytest_cache"
) {
return Ok(());
}
for entry in fs::read_dir(dir).map_err(error::DecapodError::IoError)? {
let entry = entry.map_err(error::DecapodError::IoError)?;
let path = entry.path();
if path.is_dir() {
recurse(&path, out)?;
} else if path.is_file() {
out.push(path);
}
}
Ok(())
}
let start = out.len();
recurse(root, out)?;
ctx.repo_files_cache
.lock()
.unwrap()
.push((root.to_path_buf(), out[start..].to_vec()));
Ok(())
}
fn validate_no_legacy_namespaces(
ctx: &ValidationContext,
working_root: &Path,
) -> Result<(), error::DecapodError> {
info("Namespace Purge Gate");
let mut files = Vec::new();
collect_repo_files(working_root, &mut files, ctx)?;
let needles = [
[".".to_string(), "globex".to_string()].concat(),
[".".to_string(), "codex".to_string()].concat(),
];
let mut offenders: Vec<(PathBuf, String)> = Vec::new();
for path in files {
if path.extension().is_some_and(|e| e == "db") {
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let is_texty = matches!(
ext,
"md" | "rs" | "toml" | "json" | "jsonl" | "yml" | "yaml" | "sh" | "lock"
);
if !is_texty {
continue;
}
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
for n in needles.iter() {
if content.contains(n) {
offenders.push((path.clone(), n.clone()));
}
}
}
if offenders.is_empty() {
pass(
"No legacy namespace references found in repo text sources",
ctx,
);
} else {
let mut msg = String::from("Forbidden legacy namespace references found:");
for (p, n) in offenders.iter().take(12) {
msg.push_str(&format!(" {}({})", p.display(), n));
}
if offenders.len() > 12 {
msg.push_str(&format!(" ... ({} total)", offenders.len()));
}
fail(&msg, ctx);
}
Ok(())
}
fn validate_embedded_self_contained(
ctx: &ValidationContext,
repo_root: &Path,
) -> Result<(), error::DecapodError> {
info("Embedded Self-Contained Gate");
if !repo_root.join("assets/constitution.json").exists() {
skip("No assets/constitution.json found (project repo)", ctx);
return Ok(());
}
let ids = assets::list_docs();
let mut offenders: Vec<String> = Vec::new();
for id in ids {
let content = match assets::get_embedded_doc(&id) {
Some(c) => c,
None => continue,
};
if content.contains(".decapod/") {
let mut legitimate_ref_count = 0usize;
for line in content.lines() {
let refs_on_line = line.matches(".decapod/").count();
if refs_on_line == 0 {
continue;
}
let is_legitimate_line = line.contains("<repo>")
|| line.contains("store:")
|| line.contains("directory")
|| line.contains("override")
|| line.contains("Override")
|| line.contains("OVERRIDE.md")
|| line.contains("Location:")
|| line.contains("primarily contain")
|| line.contains(".decapod/context/")
|| line.contains(".decapod/memory/")
|| line.contains("intended as")
|| line.contains(".decapod/knowledge/")
|| line.contains(".decapod/data/")
|| line.contains(".decapod/workspaces/")
|| line.contains(".decapod/generated/")
|| line.contains(".decapod/generated/specs/")
|| line.contains(".decapod/generated/policy/")
|| line.contains(".decapod/policy/")
|| line.contains(".decapod/config.toml")
|| line.contains("repo-scoped");
if is_legitimate_line {
legitimate_ref_count += refs_on_line;
}
}
let total_decapod_refs = content.matches(".decapod/").count();
if total_decapod_refs > legitimate_ref_count {
offenders.push(id);
}
}
}
if offenders.is_empty() {
pass(
"Embedded constitution files contain no invalid .decapod/ references",
ctx,
);
} else {
let mut msg =
String::from("Embedded constitution files contain invalid .decapod/ references:");
for id in offenders.iter().take(8) {
msg.push_str(&format!(" {}", id));
}
if offenders.len() > 8 {
msg.push_str(&format!(" ... ({} total)", offenders.len()));
}
fail(&msg, ctx);
}
Ok(())
}
fn pass(_message: &str, ctx: &ValidationContext) {
ctx.pass_count.fetch_add(1, Ordering::Relaxed);
}
fn fail(message: &str, ctx: &ValidationContext) {
ctx.fail_count.fetch_add(1, Ordering::Relaxed);
ctx.fails.lock().unwrap().push(message.to_string());
}
fn skip(_message: &str, ctx: &ValidationContext) {
ctx.pass_count.fetch_add(1, Ordering::Relaxed);
}
fn warn(message: &str, ctx: &ValidationContext) {
ctx.warn_count.fetch_add(1, Ordering::Relaxed);
ctx.warns.lock().unwrap().push(message.to_string());
}
fn info(_message: &str) {}
fn count_tasks_in_db(db_path: &Path) -> Result<i64, error::DecapodError> {
let conn = db::db_connect_for_validate(&db_path.to_string_lossy())?;
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get(0))
.map_err(error::DecapodError::RusqliteError)?;
Ok(count)
}
fn fetch_tasks_fingerprint(db_path: &Path) -> Result<String, error::DecapodError> {
let conn = db::db_connect_for_validate(&db_path.to_string_lossy())?;
let mut stmt = conn
.prepare("SELECT id,title,status,updated_at,dir_path,scope,priority FROM tasks ORDER BY id")
.map_err(error::DecapodError::RusqliteError)?;
let rows = stmt
.query_map([], |row| {
Ok(serde_json::json!({
"id": row.get::<_, String>(0)?,
"title": row.get::<_, String>(1)?,
"status": row.get::<_, String>(2)?,
"updated_at": row.get::<_, String>(3)?,
"dir_path": row.get::<_, String>(4)?,
"scope": row.get::<_, String>(5)?,
"priority": row.get::<_, String>(6)?,
}))
})
.map_err(error::DecapodError::RusqliteError)?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(error::DecapodError::RusqliteError)?);
}
Ok(serde_json::to_string(&out).unwrap())
}
fn validate_user_store_blank_slate(ctx: &ValidationContext) -> Result<(), error::DecapodError> {
info("Store: user (blank-slate semantics)");
let tmp_root = std::env::temp_dir().join(format!(
"decapod_validate_user_{}",
crate::core::ulid::new_ulid()
));
fs::create_dir_all(&tmp_root).map_err(error::DecapodError::IoError)?;
todo::initialize_todo_db(&tmp_root)?;
let db_path = tmp_root.join("todo.db");
let n = count_tasks_in_db(&db_path)?;
if n == 0 {
pass("User store starts empty (no automatic seeding)", ctx);
} else {
fail(
&format!(
"User store is not empty on fresh init ({} task(s) found)",
n
),
ctx,
);
}
Ok(())
}
fn validate_repo_store_dogfood(
store: &Store,
ctx: &ValidationContext,
_working_root: &Path,
) -> Result<(), error::DecapodError> {
info("Store: repo (dogfood backlog semantics)");
let events = store.root.join("todo.events.jsonl");
if !events.is_file() {
fail("Repo store missing todo.events.jsonl", ctx);
return Ok(());
}
let content = fs::read_to_string(&events).map_err(error::DecapodError::IoError)?;
let add_count = content
.lines()
.filter(|l| l.contains("\"event_type\":\"task.add\""))
.count();
pass(
&format!(
"Repo backlog event log present ({} task.add events)",
add_count
),
ctx,
);
let db_path = store.root.join("todo.db");
if !db_path.is_file() {
fail("Repo store missing todo.db", ctx);
return Ok(());
}
let broker = DbBroker::new(&store.root);
let replay_report = broker.verify_replay()?;
if replay_report.divergences.is_empty() {
pass("Audit log integrity verified (no pending event gaps)", ctx);
} else {
warn(
&format!(
"Audit log contains {} potential crash divergence(s); historical pending entries detected. Run `decapod data broker verify` for details.",
replay_report.divergences.len(),
),
ctx,
);
}
let tmp_root = std::env::temp_dir().join(format!(
"decapod_validate_repo_{}",
crate::core::ulid::new_ulid()
));
fs::create_dir_all(&tmp_root).map_err(error::DecapodError::IoError)?;
let tmp_db = tmp_root.join("todo.db");
let _events = todo::rebuild_db_from_events(&events, &tmp_db)?;
let fp_a = fetch_tasks_fingerprint(&db_path)?;
let fp_b = fetch_tasks_fingerprint(&tmp_db)?;
if fp_a == fp_b {
pass(
"Repo todo.db matches deterministic rebuild from todo.events.jsonl",
ctx,
);
} else {
fail(
"Repo todo.db does NOT match rebuild from todo.events.jsonl",
ctx,
);
}
Ok(())
}
fn validate_repo_map(
ctx: &ValidationContext,
_working_root: &Path, ) -> Result<(), error::DecapodError> {
info("Repo Map");
pass(
"Methodology constitution checks will verify embedded docs.",
ctx,
);
let required_specs = ["specs/INTENT", "specs/SYSTEM"];
let required_methodology = ["methodology/ARCHITECTURE"];
for r in required_specs {
if crate::core::assets::get_doc(r).is_some() {
pass(&format!("Constitution doc {} present (embedded)", r), ctx);
} else {
fail(&format!("Constitution doc {} missing (embedded)", r), ctx);
}
}
for r in required_methodology {
if crate::core::assets::get_doc(r).is_some() {
pass(&format!("Constitution doc {} present (embedded)", r), ctx);
} else {
fail(&format!("Constitution doc {} missing (embedded)", r), ctx);
}
}
Ok(())
}
fn validate_docs_templates_bucket(
ctx: &ValidationContext,
working_root: &Path,
) -> Result<(), error::DecapodError> {
info("Entrypoint Gate");
let required = ["AGENTS.md", "CLAUDE.md", "GEMINI.md", "CODEX.md"];
for a in required {
let p = working_root.join(a);
if p.is_file() {
pass(&format!("Root entrypoint {} present", a), ctx);
} else {
fail(
&format!("Root entrypoint {} missing from project root", a),
ctx,
);
}
}
if working_root.join(".decapod").join("README.md").is_file() {
pass(".decapod/README.md present", ctx);
} else {
fail(".decapod/README.md missing", ctx);
}
let forbidden_docs = working_root.join(".decapod").join("docs");
if forbidden_docs.exists() {
fail(
"Decapod internal docs were copied into .decapod/docs/ (Forbidden)",
ctx,
);
} else {
pass(
"Decapod internal docs correctly excluded from project repo",
ctx,
);
}
let forbidden_projects = working_root.join(".decapod").join("projects");
if forbidden_projects.exists() {
fail("Legacy .decapod/projects/ directory found (Forbidden)", ctx);
} else {
pass(".decapod/projects/ correctly absent", ctx);
}
Ok(())
}
fn validate_entrypoint_invariants(
ctx: &ValidationContext,
working_root: &Path,
) -> Result<(), error::DecapodError> {
info("Four Invariants Gate");
let agents_path = working_root.join("AGENTS.md");
if !agents_path.is_file() {
fail("AGENTS.md missing, cannot check invariants", ctx);
return Ok(());
}
let content = fs::read_to_string(&agents_path).map_err(error::DecapodError::IoError)?;
let normalized = content.to_ascii_lowercase();
let exact_invariants = [
("core/decapod", "Router pointer to core/DECAPOD"),
("cargo install decapod", "Version update gate language"),
("decapod validate", "Validation gate language"),
(
"decapod docs ingest",
"Constitution ingestion gate language",
),
(
r#"decapod rpc --op constitution.get --params '{"section":"core/decapod"}'"#,
"Core constitution RPC mandate language",
),
("stop if", "Stop-if-missing behavior"),
("docker git workspaces", "Docker workspace mandate language"),
(
"decapod todo claim --id <task-id>",
"Task claim-before-work mandate language",
),
(
"request elevated permissions before docker/container workspace commands",
"Elevated-permissions mandate language",
),
(
"decapod_session_password",
"Per-agent session password mandate language",
),
("via decapod cli", "Jail rule: .decapod access is CLI-only"),
(
"interface abstraction boundary",
"Control-plane opacity language",
),
(
"strict dependency: you are strictly bound to the decapod governance kernel",
"Agent dependency enforcement language",
),
("✅", "Four invariants checklist format"),
];
let mut all_present = true;
for (marker, description) in exact_invariants {
let present = if marker == "✅" {
content.contains(marker)
} else {
normalized.contains(marker)
};
if present {
pass(&format!("Invariant present: {}", description), ctx);
} else {
fail(&format!("Invariant missing: {}", description), ctx);
all_present = false;
}
}
let legacy_routers = ["MAESTRO.md", "GLOBEX.md", "CODEX.md\" as router"];
for legacy in legacy_routers {
if content.contains(legacy) {
fail(
&format!("AGENTS.md contains legacy router reference: {}", legacy),
ctx,
);
all_present = false;
}
}
let line_count = content.lines().count();
const MAX_AGENTS_LINES: usize = 120;
if line_count <= MAX_AGENTS_LINES {
pass(
&format!(
"AGENTS.md is thin ({} lines ≤ {})",
line_count, MAX_AGENTS_LINES
),
ctx,
);
} else {
fail(
&format!(
"AGENTS.md exceeds line limit ({} lines > {})",
line_count, MAX_AGENTS_LINES
),
ctx,
);
all_present = false;
}
const MAX_AGENT_SPECIFIC_LINES: usize = 70;
for agent_file in ["CLAUDE.md", "GEMINI.md", "CODEX.md"] {
let agent_path = working_root.join(agent_file);
if !agent_path.is_file() {
fail(&format!("{} missing from project root", agent_file), ctx);
all_present = false;
continue;
}
let agent_content =
fs::read_to_string(&agent_path).map_err(error::DecapodError::IoError)?;
if agent_content.contains("See `AGENTS.md`") || agent_content.contains("AGENTS.md") {
pass(&format!("{} defers to AGENTS.md", agent_file), ctx);
} else {
fail(&format!("{} does not reference AGENTS.md", agent_file), ctx);
all_present = false;
}
if agent_content.contains("core/DECAPOD") {
pass(&format!("{} references core/DECAPOD", agent_file), ctx);
} else {
fail(
&format!("{} missing canonical router reference (.json)", agent_file),
ctx,
);
all_present = false;
}
if agent_content.contains("decapod docs show")
|| agent_content.contains("docs show")
|| agent_content.contains("(constitution/")
{
fail(
&format!(
"{} references docs CLI or direct constitution paths; use constitution.get RPC",
agent_file
),
ctx,
);
all_present = false;
} else if agent_content.contains("constitution.get") {
pass(
&format!("{} references constitution.get RPC", agent_file),
ctx,
);
} else {
fail(
&format!("{} missing constitution.get RPC reference", agent_file),
ctx,
);
all_present = false;
}
if agent_content.contains(".decapod files are accessed only via decapod CLI") {
pass(
&format!("{} includes .decapod CLI-only jail rule", agent_file),
ctx,
);
} else {
fail(
&format!("{} missing .decapod CLI-only jail rule marker", agent_file),
ctx,
);
all_present = false;
}
if agent_content.contains("Docker git workspaces") {
pass(
&format!("{} includes Docker workspace mandate", agent_file),
ctx,
);
} else {
fail(
&format!("{} missing Docker workspace mandate marker", agent_file),
ctx,
);
all_present = false;
}
if agent_content
.contains("request elevated permissions before Docker/container workspace commands")
{
pass(
&format!("{} includes elevated-permissions mandate", agent_file),
ctx,
);
} else {
fail(
&format!("{} missing elevated-permissions mandate marker", agent_file),
ctx,
);
all_present = false;
}
if agent_content.contains("DECAPOD_SESSION_PASSWORD") {
pass(
&format!("{} includes per-agent session password mandate", agent_file),
ctx,
);
} else {
fail(
&format!(
"{} missing per-agent session password mandate marker",
agent_file
),
ctx,
);
all_present = false;
}
if agent_content.contains("decapod todo claim --id <task-id>") {
pass(
&format!("{} includes claim-before-work mandate", agent_file),
ctx,
);
} else {
fail(
&format!("{} missing claim-before-work mandate marker", agent_file),
ctx,
);
all_present = false;
}
if agent_content.contains("decapod todo add \"<task>\"") {
pass(
&format!("{} includes task creation mandate", agent_file),
ctx,
);
} else {
fail(
&format!("{} missing task creation mandate marker", agent_file),
ctx,
);
all_present = false;
}
if agent_content.contains(".decapod/workspaces") {
pass(
&format!("{} includes canonical workspace path mandate", agent_file),
ctx,
);
} else {
fail(
&format!(
"{} missing canonical workspace path marker (`.decapod/workspaces`)",
agent_file
),
ctx,
);
all_present = false;
}
if agent_content.contains(".claude/worktrees") {
let mut has_forbidden_positive_reference = false;
for line in agent_content.lines() {
if !line.contains(".claude/worktrees") {
continue;
}
let lower = line.to_ascii_lowercase();
let is_negative_context = lower.contains("never")
|| lower.contains("forbid")
|| lower.contains("non-canonical")
|| lower.contains("must not")
|| lower.contains("do not");
if !is_negative_context {
has_forbidden_positive_reference = true;
break;
}
}
if has_forbidden_positive_reference {
fail(
&format!(
"{} references forbidden non-canonical worktree path `.claude/worktrees`",
agent_file
),
ctx,
);
all_present = false;
} else {
pass(
&format!(
"{} explicitly forbids `.claude/worktrees` non-canonical path",
agent_file
),
ctx,
);
}
}
if agent_content
.to_ascii_lowercase()
.contains(r#"decapod rpc --op constitution.get --params '{"section":"core/decapod"}'"#)
{
pass(
&format!(
"{} includes core constitution ingestion mandate",
agent_file
),
ctx,
);
} else {
fail(
&format!(
"{} missing core constitution ingestion mandate marker",
agent_file
),
ctx,
);
all_present = false;
}
if agent_content.contains("cargo install decapod") {
pass(&format!("{} includes version update step", agent_file), ctx);
} else {
fail(
&format!(
"{} missing version update step (`cargo install decapod`)",
agent_file
),
ctx,
);
all_present = false;
}
let agent_lines = agent_content.lines().count();
if agent_lines <= MAX_AGENT_SPECIFIC_LINES {
pass(
&format!(
"{} is thin ({} lines ≤ {})",
agent_file, agent_lines, MAX_AGENT_SPECIFIC_LINES
),
ctx,
);
} else {
fail(
&format!(
"{} exceeds line limit ({} lines > {})",
agent_file, agent_lines, MAX_AGENT_SPECIFIC_LINES
),
ctx,
);
all_present = false;
}
let duplication_markers = [
"## Lifecycle States", "## Validation Rules", "### Proof Gates", "## Store Model", ];
for marker in duplication_markers {
if agent_content.contains(marker) {
fail(
&format!(
"{} contains duplicated contract details ({})",
agent_file, marker
),
ctx,
);
all_present = false;
}
}
}
if all_present {
pass("All entrypoint files follow thin waist architecture", ctx);
}
Ok(())
}
fn validate_interface_contract_bootstrap(
ctx: &ValidationContext,
repo_root: &Path,
) -> Result<(), error::DecapodError> {
info("Interface Contract Bootstrap Gate");
if !repo_root.join("assets/constitution.json").exists() {
skip(
"No assets/constitution.json found (project repo); skipping interface bootstrap checks",
ctx,
);
return Ok(());
}
let risk_policy_id = "interfaces/RISK_POLICY_GATE";
let context_pack_id = "interfaces/AGENT_CONTEXT_PACK";
for (id, label) in [
(risk_policy_id, "RISK_POLICY_GATE interface"),
(context_pack_id, "AGENT_CONTEXT_PACK interface"),
] {
if assets::get_embedded_doc(id).is_some() {
pass(
&format!("{} present in embedded assets: {}", label, id),
ctx,
);
} else {
fail(
&format!("{} missing from embedded assets: {}", label, id),
ctx,
);
}
}
if let Some(content) = assets::get_merged_doc(repo_root, risk_policy_id) {
for marker in [
"Authority:",
"Layer: Interfaces",
"Binding: Yes",
"Scope:",
"Non-goals:",
"## 3. Current-Head SHA Discipline",
"## 6. Browser Evidence Manifest (UI/Critical Flows)",
"## 8. Truth Labels and Upgrade Path",
"## 10. Contract Example (JSON)",
"## Core Router",
] {
if content.contains(marker) {
pass(
&format!("RISK_POLICY_GATE includes marker: {}", marker),
ctx,
);
} else {
fail(&format!("RISK_POLICY_GATE missing marker: {}", marker), ctx);
}
}
}
if let Some(content) = assets::get_merged_doc(repo_root, context_pack_id) {
for marker in [
"Authority:",
"Layer: Interfaces",
"Binding: Yes",
"Scope:",
"Non-goals:",
"## 2. Deterministic Load Order",
"## 3. Mutation Authority",
"## 4. Memory Distillation Contract",
"## 8. Truth Labels and Upgrade Path",
"## Core Router",
] {
if content.contains(marker) {
pass(
&format!("AGENT_CONTEXT_PACK includes marker: {}", marker),
ctx,
);
} else {
fail(
&format!("AGENT_CONTEXT_PACK missing marker: {}", marker),
ctx,
);
}
}
}
Ok(())
}
fn extract_md_version(content: &str) -> Option<String> {
for line in content.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("- v") {
let v_and_rest = rest.trim();
if !v_and_rest.is_empty() {
return v_and_rest.split(':').next().map(|s| s.trim().to_string());
}
}
}
None
}
fn validate_health_purity(
ctx: &ValidationContext,
working_root: &Path,
) -> Result<(), error::DecapodError> {
info("Health Purity Gate");
let mut files = Vec::new();
collect_repo_files(working_root, &mut files, ctx)?;
let forbidden =
Regex::new(r"(?i)\(health:\s*(VERIFIED|ASSERTED|STALE|CONTRADICTED)\)").unwrap();
let mut offenders = Vec::new();
let generated_path = working_root.join(".decapod").join("generated");
for path in files {
if path.extension().is_some_and(|e| e == "md") {
if path.starts_with(&generated_path) {
continue;
}
let content = fs::read_to_string(&path).unwrap_or_default();
if forbidden.is_match(&content).unwrap_or(false) {
offenders.push(path);
}
}
}
if offenders.is_empty() {
pass(
"No manual health status values found in authoritative docs",
ctx,
);
} else {
fail(
&format!(
"Manual health values found in non-generated files: {:?}",
offenders
),
ctx,
);
}
Ok(())
}
fn validate_project_scoped_state(
store: &Store,
ctx: &ValidationContext,
working_root: &Path,
) -> Result<(), error::DecapodError> {
info("Project-Scoped State Gate");
if store.kind != StoreKind::Repo {
skip("Not in repo mode; skipping state scoping check", ctx);
return Ok(());
}
let mut offenders = Vec::new();
for entry in fs::read_dir(working_root).map_err(error::DecapodError::IoError)? {
let entry = entry.map_err(error::DecapodError::IoError)?;
let path = entry.path();
if path.is_file() {
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
if matches!(ext, "db" | "jsonl") {
offenders.push(path);
}
}
}
if offenders.is_empty() {
pass("All state is correctly scoped within .decapod/", ctx);
} else {
fail(
&format!(
"Found Decapod state files outside .decapod/: {:?}",
offenders
),
ctx,
);
}
Ok(())
}
fn validate_generated_artifact_whitelist(
store: &Store,
ctx: &ValidationContext,
working_root: &Path,
) -> Result<(), error::DecapodError> {
info("Generated Artifact Whitelist Gate");
if store.kind != StoreKind::Repo {
skip(
"Not in repo mode; skipping generated artifact whitelist check",
ctx,
);
return Ok(());
}
let gitignore_path = working_root.join(".gitignore");
let gitignore = fs::read_to_string(&gitignore_path).map_err(error::DecapodError::IoError)?;
for rule in DECAPOD_GITIGNORE_RULES {
if gitignore.lines().any(|line| line.trim() == *rule) {
pass(&format!("Gitignore contains required rule '{}'", rule), ctx);
} else {
fail(
&format!(
"Missing .gitignore rule '{}' for generated/data whitelist enforcement",
rule
),
ctx,
);
}
}
let output = std::process::Command::new("git")
.arg("-C")
.arg(working_root)
.args(["ls-files", ".decapod/generated", ".decapod/data"])
.output();
let output = match output {
Ok(o) if o.status.success() => o,
Ok(_) | Err(_) => {
warn(
"Unable to evaluate tracked generated artifacts via git ls-files; skipping tracked whitelist check",
ctx,
);
return Ok(());
}
};
let allowed_tracked = [
".decapod/generated/Dockerfile",
".decapod/data/knowledge.promotions.jsonl",
".decapod/generated/specs/.manifest",
".decapod/generated/specs/.manifest.json",
".decapod/generated/policy/context_capsule_policy.json",
".decapod/generated/artifacts/provenance/kcr_trend.jsonl",
];
let mut offenders = Vec::new();
for line in String::from_utf8_lossy(&output.stdout).lines() {
let path = line.trim();
if path.is_empty() {
continue;
}
let is_allowed_exact = allowed_tracked.iter().any(|allowed| allowed == &path);
let is_allowed_context_json = path.starts_with(".decapod/generated/context/")
&& path.ends_with(".json")
&& !path.contains("/../");
let is_allowed_provenance_json = path
.starts_with(".decapod/generated/artifacts/provenance/")
&& path.ends_with(".json")
&& !path.contains("/../");
let is_allowed_specs_md = path.starts_with(".decapod/generated/specs/")
&& path.ends_with(".md")
&& !path.contains("/../");
let is_allowed_custody_md = path.starts_with(".decapod/generated/artifacts/custody/")
&& path.ends_with(".md")
&& !path.contains("/../");
if !is_allowed_exact
&& !is_allowed_context_json
&& !is_allowed_provenance_json
&& !is_allowed_specs_md
&& !is_allowed_custody_md
{
offenders.push(path.to_string());
}
}
if offenders.is_empty() {
pass(
"Tracked generated artifacts are restricted to the whitelist",
ctx,
);
} else {
fail(
&format!(
"Tracked non-whitelisted generated artifacts found: {:?}. Keep generated files ignored unless explicitly allowlisted.",
offenders
),
ctx,
);
}
Ok(())
}
fn validate_project_config_toml(
ctx: &ValidationContext,
repo_root: &Path,
) -> Result<(), error::DecapodError> {
info("Project Config Gate");
let config_path = repo_root.join(".decapod").join("config.toml");
if !config_path.exists() {
warn(
"Missing .decapod/config.toml; rerun `decapod init` to scaffold repo context configuration.",
ctx,
);
return Ok(());
}
let raw = fs::read_to_string(&config_path).map_err(error::DecapodError::IoError)?;
let value: toml::Value = toml::from_str(&raw).map_err(|e| {
error::DecapodError::ValidationError(format!("Invalid .decapod/config.toml syntax: {}", e))
})?;
let schema_version = value
.get("schema_version")
.and_then(|v| v.as_str())
.unwrap_or("");
if schema_version == "1.0.0" {
pass("Project config schema_version is valid (1.0.0)", ctx);
} else {
fail(
"Project config schema_version must be 1.0.0 in .decapod/config.toml",
ctx,
);
}
if value.get("repo").is_some() && value.get("init").is_some() {
pass(
"Project config contains required [repo] and [init] tables",
ctx,
);
} else {
fail(
"Project config missing required [repo] or [init] table",
ctx,
);
}
let repo_table = value.get("repo").and_then(|v| v.as_table());
let has_intent_anchor = repo_table
.and_then(|t| t.get("product_summary"))
.and_then(|v| v.as_str())
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if has_intent_anchor {
pass(
"Project config captures repo.product_summary intent anchor",
ctx,
);
} else {
fail(
"Project config missing repo.product_summary (intent anchor).",
ctx,
);
}
let has_architecture_direction = repo_table
.and_then(|t| {
t.get("architecture_direction")
.or_else(|| t.get("architecture_intent"))
})
.and_then(|v| v.as_str())
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if has_architecture_direction {
pass("Project config captures repo.architecture_direction", ctx);
} else {
fail("Project config missing repo.architecture_direction.", ctx);
}
let has_done_criteria = repo_table
.and_then(|t| t.get("done_criteria"))
.and_then(|v| v.as_str())
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if has_done_criteria {
pass(
"Project config captures repo.done_criteria proof target",
ctx,
);
} else {
warn(
"Project config missing repo.done_criteria; init should capture explicit done evidence.",
ctx,
);
}
Ok(())
}
fn validate_project_specs_docs(
ctx: &ValidationContext,
repo_root: &Path,
) -> Result<(), error::DecapodError> {
info("Project Specs Architecture Gate");
let specs_dir = repo_root.join(LOCAL_PROJECT_SPECS_DIR);
if !specs_dir.exists() {
warn(
"Project specs directory missing (.decapod/generated/specs/). Run `decapod init --force` to scaffold intent/architecture docs.",
ctx,
);
return Ok(());
}
for spec in LOCAL_PROJECT_SPECS {
let path = repo_root.join(spec.path);
let file = spec.path;
if path.exists() {
pass(&format!("Project specs file present: {}", file), ctx);
} else if matches!(
file,
LOCAL_PROJECT_SPECS_SEMANTICS
| LOCAL_PROJECT_SPECS_OPERATIONS
| LOCAL_PROJECT_SPECS_SECURITY
) {
warn(
&format!(
"Recommended project spec missing (scaffold-v2+): {}. Run `decapod init --force` to add the expanded spec surface.",
file
),
ctx,
);
} else {
fail(
&format!("Missing required project specs file: {}", file),
ctx,
);
}
}
let manifest_path = repo_root.join(LOCAL_PROJECT_SPECS_MANIFEST);
let manifest = read_specs_manifest(repo_root)?;
if manifest.is_none() {
warn(
&format!(
"TASK: Project specs manifest missing at {}. Run `decapod init --force` to generate scaffold metadata, then hydrate `.decapod/generated/specs/*.md`.",
manifest_path.display()
),
ctx,
);
}
if let Some(manifest) = manifest {
if manifest.schema_version == LOCAL_PROJECT_SPECS_MANIFEST_SCHEMA {
pass("Project specs manifest schema is current", ctx);
} else {
warn(
&format!(
"TASK: Project specs manifest schema mismatch (found {}, expected {}). Re-run `decapod init --force` then refresh specs.",
manifest.schema_version, LOCAL_PROJECT_SPECS_MANIFEST_SCHEMA
),
ctx,
);
}
let mut untouched_templates = Vec::new();
for entry in &manifest.files {
let path = repo_root.join(&entry.path);
if !path.exists() {
continue;
}
let body = fs::read_to_string(&path).map_err(error::DecapodError::IoError)?;
let current_hash = hash_text(&body);
if current_hash == entry.template_hash {
untouched_templates.push(entry.path.clone());
}
}
if untouched_templates.is_empty() {
pass(
"Project specs are not raw scaffold templates (content evolved)",
ctx,
);
} else {
warn(
&format!(
"TASK: Generated specs still match scaffold template for {:?}. Hydrate these docs with repo-specific details before implementation promotion.",
untouched_templates
),
ctx,
);
}
let current_repo_fp = repo_signal_fingerprint(repo_root)?;
if current_repo_fp == manifest.repo_signal_fingerprint {
pass(
"Project specs manifest repo-signal fingerprint is current",
ctx,
);
} else {
warn(
"TASK: Significant repo surfaces changed since specs scaffold/hydration. Review and update INTENT/ARCHITECTURE/INTERFACES/VALIDATION accordingly.",
ctx,
);
}
}
let architecture_path = repo_root.join(LOCAL_PROJECT_SPECS_ARCHITECTURE);
if architecture_path.exists() {
let architecture =
fs::read_to_string(&architecture_path).map_err(error::DecapodError::IoError)?;
let required_new = [
"# Architecture",
"## Direction",
"## Current Facts",
"## Topology",
"## Execution Path",
"## Concurrency and Runtime Model",
"## Deployment Topology",
"## Data and Contracts",
"## Delivery Plan",
"## Risks and Mitigations",
];
let required_legacy = [
"# Architecture",
"## Integrated Surface",
"## Implementation Strategy",
"## System Topology",
"## Service Contracts",
"## Delivery Plan",
"## Risks and Mitigations",
];
let has_new = required_new.iter().all(|s| architecture.contains(s));
let has_legacy = required_legacy.iter().all(|s| architecture.contains(s));
if has_new || has_legacy {
pass(
"Architecture spec contains required engineering sections",
ctx,
);
} else {
fail(
"Architecture spec missing required section groups (expected new or legacy scaffold structure).",
ctx,
);
}
if architecture.contains("```mermaid") || architecture.contains("```text") {
pass(
"Architecture spec contains required topology diagram block",
ctx,
);
} else {
fail(
"Architecture spec missing topology diagram block (`mermaid` or `text` fenced block)",
ctx,
);
}
if architecture.contains(
"Describe the architecture in 5-8 dense sentences focused on deployment reality, system boundaries, and operational risks.",
) {
fail(
"Architecture spec still has placeholder executive summary; derive architecture from explicit intent.",
ctx,
);
} else {
pass("Architecture spec has non-placeholder executive summary", ctx);
}
let dense_line_count = architecture
.lines()
.filter(|line| !line.trim().is_empty())
.count();
if dense_line_count >= 35 {
pass("Architecture spec meets minimum density threshold", ctx);
} else {
fail(
"Architecture spec is too sparse (<35 non-empty lines); expand it to an engineer-ready overview",
ctx,
);
}
}
let intent_path = repo_root.join(LOCAL_PROJECT_SPECS_INTENT);
if intent_path.exists() {
let intent = fs::read_to_string(intent_path).map_err(error::DecapodError::IoError)?;
let required_intent_sections = [
"# Intent",
"## Product Outcome",
"## Scope",
"## Constraints",
"## Acceptance Criteria",
];
let mut missing = Vec::new();
for section in required_intent_sections {
if !intent.contains(section) {
missing.push(section);
}
}
if missing.is_empty() {
pass("Intent spec contains required planning sections", ctx);
} else {
fail(
&format!("Intent spec missing required sections: {:?}", missing),
ctx,
);
}
if intent.contains("Define the user-visible outcome in one paragraph.") {
fail(
"Intent spec still has placeholder product outcome; capture explicit intent before implementation.",
ctx,
);
} else if intent.contains("against explicit user intent with proof-backed completion.") {
warn(
"TASK: Intent outcome still reads as generic scaffold text; replace it with explicit user/problem outcome.",
ctx,
);
} else {
pass("Intent spec has non-placeholder product outcome", ctx);
}
}
let interfaces_path = repo_root.join(LOCAL_PROJECT_SPECS_INTERFACES);
if interfaces_path.exists() {
let interfaces =
fs::read_to_string(&interfaces_path).map_err(error::DecapodError::IoError)?;
for section in [
"# Interfaces",
"## Inbound Contracts",
"## Outbound Dependencies",
"## Data Ownership",
"## Failure Semantics",
] {
if !interfaces.contains(section) {
fail(
&format!("Interfaces spec missing required section: {}", section),
ctx,
);
}
}
pass("Interfaces spec contains required contract sections", ctx);
}
let validation_path = repo_root.join(LOCAL_PROJECT_SPECS_VALIDATION);
if validation_path.exists() {
let validation =
fs::read_to_string(&validation_path).map_err(error::DecapodError::IoError)?;
for section in [
"# Validation",
"## Proof Surfaces",
"## Promotion Gates",
"## Evidence Artifacts",
"## Regression Guardrails",
] {
if !validation.contains(section) {
fail(
&format!("Validation spec missing required section: {}", section),
ctx,
);
}
}
pass("Validation spec contains required proof/gate sections", ctx);
if validation.contains("Add repository-specific test command(s) here.") {
warn(
"TASK: Validation spec still has placeholder test command guidance; add concrete test/integration commands.",
ctx,
);
}
}
let semantics_path = repo_root.join(LOCAL_PROJECT_SPECS_SEMANTICS);
if semantics_path.exists() {
let semantics =
fs::read_to_string(&semantics_path).map_err(error::DecapodError::IoError)?;
for section in ["# Semantics", "## State Machines", "## Invariants"] {
if !semantics.contains(section) {
fail(
&format!("Semantics spec missing required section: {}", section),
ctx,
);
}
}
pass("Semantics spec contains required sections", ctx);
}
let operations_path = repo_root.join(LOCAL_PROJECT_SPECS_OPERATIONS);
if operations_path.exists() {
let operations =
fs::read_to_string(&operations_path).map_err(error::DecapodError::IoError)?;
for section in [
"# Operations",
"## Service Level Objectives",
"## Monitoring",
"## Incident Response",
] {
if !operations.contains(section) {
fail(
&format!("Operations spec missing required section: {}", section),
ctx,
);
}
}
pass("Operations spec contains required sections", ctx);
}
let security_path = repo_root.join(LOCAL_PROJECT_SPECS_SECURITY);
if security_path.exists() {
let security = fs::read_to_string(&security_path).map_err(error::DecapodError::IoError)?;
for section in [
"# Security",
"## Threat Model",
"## Authentication",
"## Authorization",
"## Data Classification",
] {
if !security.contains(section) {
fail(
&format!("Security spec missing required section: {}", section),
ctx,
);
}
}
pass("Security spec contains required sections", ctx);
}
Ok(())
}
fn validate_machine_contract(
ctx: &ValidationContext,
repo_root: &Path,
) -> Result<(), error::DecapodError> {
info("Machine Contract Drift Detection Gate");
let binary_path =
std::env::current_exe().map_err(|e| error::DecapodError::ValidationError(e.to_string()))?;
let capabilities_output = std::process::Command::new(&binary_path)
.current_dir(repo_root)
.args(["capabilities", "--format", "json"])
.output()
.map_err(|e| {
error::DecapodError::ValidationError(format!("Failed to run capabilities: {}", e))
})?;
if !capabilities_output.status.success() {
pass(
"Could not verify machine contract (capabilities failed)",
ctx,
);
return Ok(());
}
let capabilities_json: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&capabilities_output.stdout)).map_err(
|e| error::DecapodError::ValidationError(format!("Invalid capabilities JSON: {}", e)),
)?;
let interlock_codes = capabilities_json["interlock_codes"]
.as_array()
.map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
.unwrap_or_default();
let required_interlock = [
"workspace_required",
"verification_required",
"store_boundary_violation",
];
let mut missing_interlock = Vec::new();
for code in required_interlock {
if !interlock_codes.contains(&code) {
missing_interlock.push(code);
}
}
if missing_interlock.is_empty() {
pass("Machine contract interlock codes match binary", ctx);
} else {
fail(
&format!(
"Binary capabilities missing interlock codes: {:?}. Binary and specs are out of sync.",
missing_interlock
),
ctx,
);
}
let capabilities_list = capabilities_json["capabilities"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v["name"].as_str())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let required_caps = [
"daemonless",
"deterministic",
"context.resolve",
"validate.run",
"workspace.ensure",
"preflight.check",
"impact.predict",
];
let mut missing_caps = Vec::new();
for cap in required_caps {
if !capabilities_list.contains(&cap) {
missing_caps.push(cap);
}
}
if missing_caps.is_empty() {
pass("Machine contract capabilities match binary", ctx);
} else {
warn(
&format!(
"Binary capabilities missing expected capabilities: {:?}",
missing_caps
),
ctx,
);
}
Ok(())
}
fn validate_spec_drift(
ctx: &ValidationContext,
repo_root: &Path,
) -> Result<(), error::DecapodError> {
info("Spec Drift Detection Gate (Hygiene)");
let interfaces_path = repo_root.join(LOCAL_PROJECT_SPECS_INTERFACES);
if !interfaces_path.exists() {
pass("No INTERFACES.md to check for hygiene", ctx);
return Ok(());
}
let interfaces = fs::read_to_string(&interfaces_path).map_err(error::DecapodError::IoError)?;
warn(
"Spec markdown drift checks are hygiene-only. Use validate_machine_contract for authoritative governance.",
ctx,
);
let key_sections = ["# Interfaces", "## Inbound Contracts", "## Data Ownership"];
let mut missing_sections = Vec::new();
for section in key_sections {
if !interfaces.contains(section) {
missing_sections.push(section);
}
}
if missing_sections.is_empty() {
pass("INTERFACES.md has structural sections", ctx);
} else {
warn(
&format!("INTERFACES.md missing sections: {:?}", missing_sections),
ctx,
);
}
for (path, name, sections) in [
(
LOCAL_PROJECT_SPECS_SEMANTICS,
"SEMANTICS.md",
vec!["# Semantics", "## State Machines", "## Invariants"],
),
(
LOCAL_PROJECT_SPECS_OPERATIONS,
"OPERATIONS.md",
vec![
"# Operations",
"## Service Level Objectives",
"## Monitoring",
"## Incident Response",
],
),
(
LOCAL_PROJECT_SPECS_SECURITY,
"SECURITY.md",
vec![
"# Security",
"## Threat Model",
"## Authentication",
"## Authorization",
"## Data Classification",
],
),
] {
let path = repo_root.join(path);
if !path.exists() {
warn(
&format!(
"{} missing (hygiene check only). Run `decapod init --force` to scaffold it.",
name
),
ctx,
);
continue;
}
let body = fs::read_to_string(&path).map_err(error::DecapodError::IoError)?;
let missing = sections
.iter()
.filter(|section| !body.contains(**section))
.copied()
.collect::<Vec<_>>();
if missing.is_empty() {
pass(&format!("{} has structural sections", name), ctx);
} else {
warn(&format!("{} missing sections: {:?}", name, missing), ctx);
}
}
Ok(())
}
fn validate_workunit_manifests_if_present(
ctx: &ValidationContext,
repo_root: &Path,
) -> Result<(), error::DecapodError> {
info("Work Unit Manifest Gate");
let workunits_dir = repo_root
.join(".decapod")
.join("governance")
.join("workunits");
if !workunits_dir.exists() {
skip("No workunit manifests found; skipping workunit gate", ctx);
return Ok(());
}
let mut files = 0usize;
for entry in fs::read_dir(&workunits_dir).map_err(error::DecapodError::IoError)? {
let entry = entry.map_err(error::DecapodError::IoError)?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
files += 1;
let raw = fs::read_to_string(&path).map_err(error::DecapodError::IoError)?;
let parsed: WorkUnitManifest = serde_json::from_str(&raw).map_err(|e| {
error::DecapodError::ValidationError(format!(
"invalid workunit manifest {}: {}",
path.display(),
e
))
})?;
let _ = parsed.canonical_json_bytes().map_err(|e| {
error::DecapodError::ValidationError(format!(
"workunit canonicalization failed for {}: {}",
path.display(),
e
))
})?;
if parsed.status == WorkUnitStatus::Verified {
workunit::validate_verified_manifest(&parsed).map_err(|e| {
error::DecapodError::ValidationError(format!(
"invalid VERIFIED workunit manifest: {} ({})",
e,
path.display()
))
})?;
workunit::verify_capsule_policy_lineage_for_task(repo_root, &parsed).map_err(|e| {
error::DecapodError::ValidationError(format!(
"invalid VERIFIED workunit manifest: {} ({})",
e,
path.display()
))
})?;
}
}
pass(
&format!(
"Workunit manifest schema check passed for {} file(s)",
files
),
ctx,
);
Ok(())
}
fn validate_context_capsules_if_present(
ctx: &ValidationContext,
repo_root: &Path,
) -> Result<(), error::DecapodError> {
info("Context Capsule Gate");
let capsules_dir = repo_root.join(".decapod").join("generated").join("context");
if !capsules_dir.exists() {
skip(
"No context capsules found; skipping context capsule gate",
ctx,
);
return Ok(());
}
let mut files = 0usize;
for entry in fs::read_dir(&capsules_dir).map_err(error::DecapodError::IoError)? {
let entry = entry.map_err(error::DecapodError::IoError)?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
files += 1;
let raw = fs::read_to_string(&path).map_err(error::DecapodError::IoError)?;
let parsed: DeterministicContextCapsule = serde_json::from_str(&raw).map_err(|e| {
error::DecapodError::ValidationError(format!(
"invalid context capsule {}: {}",
path.display(),
e
))
})?;
let expected = parsed.computed_hash_hex().map_err(|e| {
error::DecapodError::ValidationError(format!(
"context capsule hash computation failed for {}: {}",
path.display(),
e
))
})?;
if parsed.capsule_hash != expected {
fail(
&format!(
"Context capsule hash mismatch in {} (expected {}, got {})",
path.display(),
expected,
parsed.capsule_hash
),
ctx,
);
}
}
pass(
&format!("Context capsule integrity checked for {} file(s)", files),
ctx,
);
Ok(())
}
fn validate_context_capsule_policy_contract(
ctx: &ValidationContext,
repo_root: &Path,
) -> Result<(), error::DecapodError> {
info("Context Capsule Policy Gate");
let (policy, path) = match capsule_policy::load_policy_contract(repo_root) {
Ok(v) => v,
Err(error::DecapodError::ValidationError(msg))
if msg.starts_with("CAPSULE_POLICY_MISSING:") =>
{
warn(
"Context capsule policy contract missing; run `decapod init --force` to scaffold .decapod/generated/policy/context_capsule_policy.json",
ctx,
);
return Ok(());
}
Err(e) => return Err(e),
};
if policy.schema_version != POLICY_SCHEMA_VERSION {
fail(
&format!(
"Context capsule policy schema mismatch at {} (actual={}, expected={})",
path.display(),
policy.schema_version,
POLICY_SCHEMA_VERSION
),
ctx,
);
}
if !policy.tiers.contains_key(&policy.default_risk_tier) {
fail(
&format!(
"Context capsule policy default_risk_tier '{}' is not declared in tiers",
policy.default_risk_tier
),
ctx,
);
}
for (tier, rule) in &policy.tiers {
if rule.allowed_scopes.is_empty() {
fail(
&format!(
"Context capsule policy tier '{}' has no allowed_scopes (fail closed)",
tier
),
ctx,
);
}
if rule.max_limit == 0 {
fail(
&format!(
"Context capsule policy tier '{}' has max_limit=0 (invalid)",
tier
),
ctx,
);
}
}
pass(
&format!(
"Context capsule policy contract parsed and validated ({})",
path.display()
),
ctx,
);
Ok(())
}
fn validate_knowledge_promotions_if_present(
ctx: &ValidationContext,
repo_root: &Path,
) -> Result<(), error::DecapodError> {
info("Knowledge Promotion Ledger Gate");
let ledger = repo_root
.join(".decapod")
.join("data")
.join("knowledge.promotions.jsonl");
if !ledger.exists() {
skip(
"No knowledge promotion ledger found; skipping promotion ledger gate",
ctx,
);
return Ok(());
}
let raw = fs::read_to_string(&ledger).map_err(error::DecapodError::IoError)?;
for (idx, line) in raw.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let v: serde_json::Value = serde_json::from_str(line).map_err(|e| {
error::DecapodError::ValidationError(format!(
"invalid promotion ledger line {} in {}: {}",
idx + 1,
ledger.display(),
e
))
})?;
for key in [
"event_id",
"ts",
"source_entry_id",
"target_class",
"evidence_refs",
"approved_by",
"actor",
"reason",
] {
if v.get(key).is_none() {
fail(
&format!(
"Knowledge promotion ledger missing '{}' on line {} ({})",
key,
idx + 1,
ledger.display()
),
ctx,
);
}
}
if v.get("target_class").and_then(|x| x.as_str()) != Some("procedural") {
fail(
&format!(
"Knowledge promotion ledger requires target_class='procedural' on line {} ({})",
idx + 1,
ledger.display()
),
ctx,
);
}
let evidence_ok = v
.get("evidence_refs")
.and_then(|x| x.as_array())
.map(|arr| {
!arr.is_empty()
&& arr
.iter()
.all(|item| item.as_str().map(|s| !s.trim().is_empty()).unwrap_or(false))
})
.unwrap_or(false);
if !evidence_ok {
fail(
&format!(
"Knowledge promotion ledger evidence_refs must be a non-empty string array on line {} ({})",
idx + 1,
ledger.display()
),
ctx,
);
}
for key in ["approved_by", "actor", "reason"] {
let non_empty = v
.get(key)
.and_then(|x| x.as_str())
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if !non_empty {
fail(
&format!(
"Knowledge promotion ledger '{}' must be a non-empty string on line {} ({})",
key,
idx + 1,
ledger.display()
),
ctx,
);
}
}
}
pass("Knowledge promotion ledger schema check passed", ctx);
Ok(())
}
fn validate_skill_cards_if_present(
ctx: &ValidationContext,
repo_root: &Path,
) -> Result<(), error::DecapodError> {
info("Skill Card Artifact Gate");
let dir = repo_root.join(".decapod").join("skills");
if !dir.exists() {
skip("No skill cards found; skipping skill card gate", ctx);
return Ok(());
}
let mut files = 0usize;
for entry in fs::read_dir(&dir).map_err(error::DecapodError::IoError)? {
let entry = entry.map_err(error::DecapodError::IoError)?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
files += 1;
let raw = fs::read_to_string(&path).map_err(error::DecapodError::IoError)?;
let parsed: SkillCard = serde_json::from_str(&raw).map_err(|e| {
error::DecapodError::ValidationError(format!(
"invalid skill card {}: {}",
path.display(),
e
))
})?;
if parsed.kind != "skill_card" || parsed.schema_version != "1.0.0" {
fail(
&format!(
"skill card {} has invalid kind/schema_version",
path.display()
),
ctx,
);
continue;
}
let mut normalized = parsed.clone();
let expected = parsed.card_hash.clone();
normalized.card_hash.clear();
normalized.generated_at.clear();
let canonical = serde_json::to_vec(&normalized).map_err(|e| {
error::DecapodError::ValidationError(format!(
"skill card canonicalization failed for {}: {}",
path.display(),
e
))
})?;
let actual = {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(&canonical);
format!("{:x}", hasher.finalize())
};
if actual != expected {
fail(
&format!(
"skill card hash mismatch in {} (expected {}, got {})",
path.display(),
expected,
actual
),
ctx,
);
}
}
pass(
&format!("Skill card integrity checked for {} file(s)", files),
ctx,
);
Ok(())
}
fn validate_skill_resolutions_if_present(
ctx: &ValidationContext,
repo_root: &Path,
) -> Result<(), error::DecapodError> {
info("Skill Resolution Artifact Gate");
let dir = repo_root.join(".decapod").join("generated").join("skills");
if !dir.exists() {
skip(
"No skill resolution artifacts found; skipping skill resolution gate",
ctx,
);
return Ok(());
}
let mut files = 0usize;
for entry in fs::read_dir(&dir).map_err(error::DecapodError::IoError)? {
let entry = entry.map_err(error::DecapodError::IoError)?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
files += 1;
let raw = fs::read_to_string(&path).map_err(error::DecapodError::IoError)?;
let parsed: SkillResolution = serde_json::from_str(&raw).map_err(|e| {
error::DecapodError::ValidationError(format!(
"invalid skill resolution {}: {}",
path.display(),
e
))
})?;
if parsed.kind != "skill_resolution" || parsed.schema_version != "1.0.0" {
fail(
&format!(
"skill resolution {} has invalid kind/schema_version",
path.display()
),
ctx,
);
continue;
}
let mut normalized = parsed.clone();
let expected = parsed.resolution_hash.clone();
normalized.resolution_hash.clear();
normalized.generated_at.clear();
let canonical = serde_json::to_vec(&normalized).map_err(|e| {
error::DecapodError::ValidationError(format!(
"skill resolution canonicalization failed for {}: {}",
path.display(),
e
))
})?;
let actual = {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(&canonical);
format!("{:x}", hasher.finalize())
};
if actual != expected {
fail(
&format!(
"skill resolution hash mismatch in {} (expected {}, got {})",
path.display(),
expected,
actual
),
ctx,
);
}
}
pass(
&format!("Skill resolution integrity checked for {} file(s)", files),
ctx,
);
Ok(())
}
fn validate_internalization_artifacts_if_present(
ctx: &ValidationContext,
repo_root: &Path,
) -> Result<(), error::DecapodError> {
info("Internalization Artifact Gate");
let artifacts_dir = repo_root
.join(".decapod")
.join("generated")
.join("artifacts")
.join("internalizations");
if !artifacts_dir.exists() {
skip(
"No internalization artifacts found; skipping internalization gate",
ctx,
);
return Ok(());
}
let mut files = 0usize;
for entry in fs::read_dir(&artifacts_dir).map_err(error::DecapodError::IoError)? {
let entry = entry.map_err(error::DecapodError::IoError)?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let manifest_path = path.join("manifest.json");
if !manifest_path.exists() {
fail(
&format!(
"Internalization artifact is missing manifest.json ({})",
path.display()
),
ctx,
);
continue;
}
files += 1;
let raw = fs::read_to_string(&manifest_path).map_err(error::DecapodError::IoError)?;
let manifest: InternalizationManifest = serde_json::from_str(&raw).map_err(|e| {
error::DecapodError::ValidationError(format!(
"invalid internalization manifest {}: {}",
manifest_path.display(),
e
))
})?;
if manifest.schema_version != internalize::SCHEMA_VERSION {
fail(
&format!(
"Internalization manifest schema mismatch in {} (actual={}, expected={})",
manifest_path.display(),
manifest.schema_version,
internalize::SCHEMA_VERSION
),
ctx,
);
}
if manifest.base_model_id.trim().is_empty() {
fail(
&format!(
"Internalization manifest missing base_model_id ({})",
manifest_path.display()
),
ctx,
);
}
if manifest.capabilities_contract.permitted_tools.is_empty() {
fail(
&format!(
"Internalization manifest must declare permitted_tools ({})",
manifest_path.display()
),
ctx,
);
}
if manifest.replay_recipe.mode == ReplayClass::Replayable
&& manifest.determinism_class != DeterminismClass::Deterministic
{
fail(
&format!(
"Internalization manifest claims replayable despite non-deterministic profile ({})",
manifest_path.display()
),
ctx,
);
}
if manifest.determinism_class == DeterminismClass::BestEffort
&& (manifest.binary_hash.trim().is_empty()
|| manifest.runtime_fingerprint.trim().is_empty())
{
fail(
&format!(
"Best-effort internalization manifest must include binary_hash and runtime_fingerprint ({})",
manifest_path.display()
),
ctx,
);
}
let inspect =
internalize::inspect_internalization(&repo_root.join(".decapod"), &manifest.id)
.map_err(|e| {
error::DecapodError::ValidationError(format!(
"internalization inspect failed for {}: {}",
manifest_path.display(),
e
))
})?;
if !inspect.integrity.adapter_hash_valid {
fail(
&format!(
"Internalization adapter hash mismatch ({})",
manifest_path.display()
),
ctx,
);
}
if inspect.integrity.source_verification == "mismatch" {
fail(
&format!(
"Internalization source hash mismatch ({})",
manifest_path.display()
),
ctx,
);
}
if !inspect.integrity.replayable_claim_valid {
fail(
&format!(
"Internalization replay metadata is inconsistent ({})",
manifest_path.display()
),
ctx,
);
}
}
let sessions_dir = repo_root
.join(".decapod")
.join("generated")
.join("sessions");
if sessions_dir.exists() {
for session_entry in fs::read_dir(&sessions_dir).map_err(error::DecapodError::IoError)? {
let session_entry = session_entry.map_err(error::DecapodError::IoError)?;
let mounts_dir = session_entry.path().join("internalize_mounts");
if !mounts_dir.exists() {
continue;
}
for mount_entry in fs::read_dir(&mounts_dir).map_err(error::DecapodError::IoError)? {
let mount_entry = mount_entry.map_err(error::DecapodError::IoError)?;
let mount_path = mount_entry.path();
if mount_path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let raw = fs::read_to_string(&mount_path).map_err(error::DecapodError::IoError)?;
let mount: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
error::DecapodError::ValidationError(format!(
"invalid internalization mount lease {}: {}",
mount_path.display(),
e
))
})?;
let lease_expires_at = mount
.get("lease_expires_at")
.and_then(|v| v.as_str())
.unwrap_or("");
if lease_expires_at.is_empty() {
fail(
&format!(
"Internalization mount missing lease_expires_at ({})",
mount_path.display()
),
ctx,
);
continue;
}
if lease_expires_at < internalize::now_iso8601().as_str() {
fail(
&format!(
"Internalization mount lease expired but still present ({})",
mount_path.display()
),
ctx,
);
}
}
}
}
pass(
&format!(
"Internalization artifact contract checked for {} artifact(s)",
files
),
ctx,
);
Ok(())
}
fn validate_schema_determinism(
ctx: &ValidationContext,
_working_root: &Path,
) -> Result<(), error::DecapodError> {
info("Schema Determinism Gate");
let run_schema = || -> Result<String, error::DecapodError> {
let snapshot = crate::deterministic_schema_envelope();
serde_json::to_string(&snapshot).map_err(|e| {
error::DecapodError::ValidationError(format!(
"schema determinism serialization failed: {}",
e
))
})
};
let s1 = run_schema()?;
let s2 = run_schema()?;
if s1 == s2 && !s1.is_empty() {
pass("Schema output is deterministic", ctx);
} else {
fail("Schema output is non-deterministic or empty", ctx);
}
Ok(())
}
fn validate_database_schema_versions(
store: &Store,
ctx: &ValidationContext,
) -> Result<(), error::DecapodError> {
info("Database Schema Version Gate");
if !matches!(store.kind, StoreKind::Repo) {
skip(
"Database schema version gate applies to repo store only",
ctx,
);
return Ok(());
}
let checks = migration::check_versioned_db_schema_expectations(&store.root)?;
for check in checks {
if !check.exists {
fail(
&format!(
"Versioned database {} is missing (expected schema_version={})",
check.db_name, check.expected_version
),
ctx,
);
continue;
}
match check.actual_version {
Some(actual) if actual == check.expected_version => {
pass(
&format!(
"{} schema_version matches expected {}",
check.db_name, check.expected_version
),
ctx,
);
}
Some(actual) => {
fail(
&format!(
"{} schema_version mismatch: actual={}, expected={}",
check.db_name, actual, check.expected_version
),
ctx,
);
}
None => {
fail(
&format!(
"{} missing readable schema_version in meta table (expected {})",
check.db_name, check.expected_version
),
ctx,
);
}
}
}
Ok(())
}
fn validate_eval_gate_if_required(
store: &Store,
ctx: &ValidationContext,
) -> Result<(), error::DecapodError> {
info("Eval Gate Requirement");
let failures = crate::plugins::eval::validate_eval_gate_if_required(&store.root)?;
if failures.is_empty() {
pass("Eval gate requirement satisfied or not configured", ctx);
} else {
for failure in failures {
fail(&failure, ctx);
}
}
Ok(())
}
fn validate_health_cache_integrity(
store: &Store,
ctx: &ValidationContext,
) -> Result<(), error::DecapodError> {
info("Health Cache Non-Authoritative Gate");
let db_path = store.root.join("health.db");
if !db_path.exists() {
skip("health.db not found; skipping health integrity check", ctx);
return Ok(());
}
let conn = db::db_connect_for_validate(&db_path.to_string_lossy())?;
let orphaned: i64 = conn.query_row(
"SELECT COUNT(*) FROM health_cache hc LEFT JOIN proof_events pe ON hc.claim_id = pe.claim_id WHERE pe.event_id IS NULL",
[],
|row| row.get(0),
).map_err(error::DecapodError::RusqliteError)?;
if orphaned == 0 {
pass("No orphaned health cache entries (integrity pass)", ctx);
} else {
warn(
&format!(
"Found {} health cache entries without proof events (might be manual writes)",
orphaned
),
ctx,
);
}
Ok(())
}
fn validate_risk_map(store: &Store, ctx: &ValidationContext) -> Result<(), error::DecapodError> {
info("Risk Map Gate");
let map_path = store.root.join("RISKMAP.json");
if map_path.exists() {
pass("Risk map (blast-radius) is present", ctx);
} else {
warn("Risk map missing (run `decapod riskmap init`)", ctx);
}
Ok(())
}
fn validate_risk_map_violations(
store: &Store,
ctx: &ValidationContext,
pre_read_broker: Option<&str>,
) -> Result<(), error::DecapodError> {
info("Zone Violation Gate");
let fallback;
let content = match pre_read_broker {
Some(c) => c,
None => {
let audit_log = store.root.join("broker.events.jsonl");
if !audit_log.exists() {
return Ok(());
}
fallback = fs::read_to_string(audit_log)?;
&fallback
}
};
{
let mut offenders = Vec::new();
for line in content.lines() {
if line.contains("\".decapod/\"") && line.contains("\"op\":\"todo.add\"") {
offenders.push(line.to_string());
}
}
if offenders.is_empty() {
pass("No risk zone violations detected in audit log", ctx);
} else {
fail(
&format!("Detected operations in protected zones: {:?}", offenders),
ctx,
);
}
}
Ok(())
}
fn validate_policy_integrity(
store: &Store,
ctx: &ValidationContext,
pre_read_broker: Option<&str>,
) -> Result<(), error::DecapodError> {
info("Policy Integrity Gates");
let db_path = store.root.join("policy.db");
if !db_path.exists() {
skip("policy.db not found; skipping policy check", ctx);
return Ok(());
}
let _conn = db::db_connect_for_validate(&db_path.to_string_lossy())?;
let fallback;
let content_opt = match pre_read_broker {
Some(c) => Some(c),
None => {
let audit_log = store.root.join("broker.events.jsonl");
if audit_log.exists() {
fallback = fs::read_to_string(audit_log)?;
Some(fallback.as_str())
} else {
None
}
}
};
if let Some(content) = content_opt {
let mut offenders = Vec::new();
for line in content.lines() {
if line.contains("\"op\":\"policy.approve\"")
&& line.contains("\"db_id\":\"health.db\"")
{
offenders.push(line.to_string());
}
}
if offenders.is_empty() {
pass(
"Approval isolation verified (no direct health mutations)",
ctx,
);
} else {
fail(
&format!(
"Policy approval directly mutated health state: {:?}",
offenders
),
ctx,
);
}
}
Ok(())
}
#[derive(Debug, Deserialize)]
struct RecursiveImprovementPass {
schema_version: String,
id: String,
observed_deficiency: String,
parent_task_ref: Option<String>,
parent_spec_ref: Option<String>,
constitutional_authority: String,
allowed_changes: Vec<String>,
forbidden_changes: Vec<String>,
touched_paths: Vec<String>,
proof_required: Vec<String>,
stop_condition: String,
risk_level: String,
requires_user_approval: bool,
user_approval_ref: Option<String>,
mutates_parent_intent: bool,
expands_scope: bool,
weakens_governance: bool,
}
fn non_empty(s: &str) -> bool {
!s.trim().is_empty()
}
fn vague_proof(proof: &str) -> bool {
let normalized = proof.trim().to_ascii_lowercase();
normalized.is_empty()
|| matches!(
normalized.as_str(),
"proof"
| "verify"
| "validation"
| "tests"
| "check"
| "review"
| "manual review"
| "looks good"
| "looks clean"
| "green"
)
|| normalized.contains("todo")
|| normalized.contains("tbd")
|| normalized.contains("some test")
}
fn vague_stop_condition(stop_condition: &str) -> bool {
let normalized = stop_condition.trim().to_ascii_lowercase();
normalized.is_empty()
|| matches!(
normalized.as_str(),
"none"
| "n/a"
| "until done"
| "until good"
| "until clean"
| "until it looks good"
| "when good"
| "when clean"
| "open ended"
| "infinite"
)
|| normalized.contains("forever")
}
fn forbidden_path_touched(forbidden: &[String], touched: &[String]) -> Option<(String, String)> {
for path in touched {
let p = path.trim().trim_start_matches("./");
for rule in forbidden {
let r = rule.trim().trim_start_matches("./");
if r.is_empty() {
continue;
}
let prefix = r.trim_end_matches('*').trim_end_matches('/');
if p == prefix || p.starts_with(&format!("{prefix}/")) {
return Some((path.clone(), rule.clone()));
}
}
}
None
}
fn validate_recursive_pass(pass: &RecursiveImprovementPass) -> Result<(), String> {
if pass.schema_version != "recursive-improvement-pass.v1" {
return Err("schema_version must be recursive-improvement-pass.v1".to_string());
}
if !non_empty(&pass.id) {
return Err("id is required".to_string());
}
if !non_empty(&pass.observed_deficiency) {
return Err("observed_deficiency is required".to_string());
}
let has_parent_task = pass.parent_task_ref.as_deref().is_some_and(non_empty);
let has_parent_spec = pass.parent_spec_ref.as_deref().is_some_and(non_empty);
if !has_parent_task && !has_parent_spec {
return Err("parent task/spec reference is required".to_string());
}
if !non_empty(&pass.constitutional_authority) {
return Err("constitutional authority is required".to_string());
}
let authority = pass.constitutional_authority.trim();
if !authority.starts_with("claim.") && !authority.contains('/') && !authority.contains(".md") {
return Err(
"constitutional authority must cite a claim id or constitution document".to_string(),
);
}
if pass.allowed_changes.is_empty() || !pass.allowed_changes.iter().all(|s| non_empty(s)) {
return Err("allowed_changes must name bounded mutation scope".to_string());
}
if pass.forbidden_changes.is_empty() || !pass.forbidden_changes.iter().all(|s| non_empty(s)) {
return Err("forbidden_changes must name forbidden mutation scope".to_string());
}
if pass.proof_required.is_empty() || pass.proof_required.iter().any(|p| vague_proof(p)) {
return Err("proof_required must contain concrete proof gates".to_string());
}
if vague_stop_condition(&pass.stop_condition) {
return Err("stop_condition is required and must prevent infinite polishing".to_string());
}
if !matches!(
pass.risk_level.as_str(),
"low" | "medium" | "high" | "critical"
) {
return Err("risk_level must be one of low, medium, high, critical".to_string());
}
if pass.requires_user_approval && !pass.user_approval_ref.as_deref().is_some_and(non_empty) {
return Err(
"user_approval_ref is required when requires_user_approval is true".to_string(),
);
}
if pass.mutates_parent_intent {
return Err("recursive pass must not mutate parent intent".to_string());
}
if pass.expands_scope {
return Err("recursive pass must not expand scope".to_string());
}
if pass.weakens_governance {
return Err(
"recursive pass must not weaken constitution, repo rules, proof gates, or boundaries"
.to_string(),
);
}
if let Some((path, rule)) = forbidden_path_touched(&pass.forbidden_changes, &pass.touched_paths)
{
return Err(format!(
"recursive pass touched forbidden path '{}' matching '{}'",
path, rule
));
}
Ok(())
}
fn validate_recursive_improvement_passes_if_present(
ctx: &ValidationContext,
repo_root: &Path,
) -> Result<(), error::DecapodError> {
info("Recursive Improvement Pass Gate");
let passes_dir = repo_root
.join(".decapod")
.join("governance")
.join("recursive_passes");
if !passes_dir.exists() {
skip(
"No recursive improvement pass artifacts found; skipping recursive pass gate",
ctx,
);
return Ok(());
}
let mut files = 0usize;
for entry in fs::read_dir(&passes_dir).map_err(error::DecapodError::IoError)? {
let entry = entry.map_err(error::DecapodError::IoError)?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
files += 1;
let raw = fs::read_to_string(&path).map_err(error::DecapodError::IoError)?;
let parsed: RecursiveImprovementPass = serde_json::from_str(&raw).map_err(|e| {
error::DecapodError::ValidationError(format!(
"invalid recursive improvement pass {}: {}",
path.display(),
e
))
})?;
validate_recursive_pass(&parsed).map_err(|e| {
error::DecapodError::ValidationError(format!(
"invalid recursive improvement pass: {} ({})",
e,
path.display()
))
})?;
}
pass(
&format!(
"Recursive improvement pass schema check passed for {} file(s)",
files
),
ctx,
);
Ok(())
}
fn validate_knowledge_integrity(
store: &Store,
ctx: &ValidationContext,
pre_read_broker: Option<&str>,
) -> Result<(), error::DecapodError> {
info("Knowledge Integrity Gate");
let db_path = store.root.join("knowledge.db");
if !db_path.exists() {
skip(
"knowledge.db not found; skipping knowledge integrity check",
ctx,
);
return Ok(());
}
let query_missing_provenance = |conn: &rusqlite::Connection| -> Result<i64, rusqlite::Error> {
conn.query_row(
"SELECT COUNT(*) FROM knowledge WHERE provenance IS NULL OR provenance = ''",
[],
|row| row.get(0),
)
};
let mut conn = db::db_connect_for_validate(&db_path.to_string_lossy())?;
let missing_provenance: i64 = match query_missing_provenance(&conn) {
Ok(v) => v,
Err(rusqlite::Error::SqliteFailure(_, Some(msg)))
if msg.contains("no such table: knowledge") =>
{
db::initialize_knowledge_db(&store.root)?;
conn = db::db_connect_for_validate(&db_path.to_string_lossy())?;
query_missing_provenance(&conn).map_err(error::DecapodError::RusqliteError)?
}
Err(e) => return Err(error::DecapodError::RusqliteError(e)),
};
if missing_provenance == 0 {
pass(
"Knowledge provenance verified (all entries have pointers)",
ctx,
);
} else {
fail(
&format!(
"Found {} knowledge entries missing mandatory provenance",
missing_provenance
),
ctx,
);
}
let procedural_missing_event_provenance: i64 = conn
.query_row(
"SELECT COUNT(*) FROM knowledge
WHERE id LIKE 'procedural/%'
AND (provenance IS NULL OR provenance = '' OR provenance NOT LIKE 'event:%')",
[],
|row| row.get(0),
)
.map_err(error::DecapodError::RusqliteError)?;
if procedural_missing_event_provenance == 0 {
pass(
"Knowledge promotion firewall verified (procedural entries carry event provenance)",
ctx,
);
} else {
fail(
&format!(
"Found {} procedural knowledge entries without event-backed provenance",
procedural_missing_event_provenance
),
ctx,
);
}
let event_ids = load_knowledge_promotion_event_ids(&store.root)?;
let mut stmt = conn
.prepare(
"SELECT provenance FROM knowledge
WHERE id LIKE 'procedural/%' AND provenance LIKE 'event:%'",
)
.map_err(error::DecapodError::RusqliteError)?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(0))
.map_err(error::DecapodError::RusqliteError)?;
let mut missing_event_refs = 0usize;
for row in rows {
let prov = row.map_err(error::DecapodError::RusqliteError)?;
let event_id = prov.trim_start_matches("event:");
if !event_ids.contains(event_id) {
missing_event_refs += 1;
}
}
if missing_event_refs == 0 {
pass("Knowledge promotion firewall ledger linkage verified", ctx);
} else {
fail(
&format!(
"Found {} procedural knowledge entries referencing missing promotion events",
missing_event_refs
),
ctx,
);
}
let fallback;
let content_opt = match pre_read_broker {
Some(c) => Some(c),
None => {
let audit_log = store.root.join("broker.events.jsonl");
if audit_log.exists() {
fallback = fs::read_to_string(audit_log)?;
Some(fallback.as_str())
} else {
None
}
}
};
if let Some(content) = content_opt {
let mut offenders = Vec::new();
for line in content.lines() {
if line.contains("\"op\":\"knowledge.add\"") && line.contains("\"db_id\":\"health.db\"")
{
offenders.push(line.to_string());
}
}
if offenders.is_empty() {
pass("No direct health promotion from knowledge detected", ctx);
} else {
fail(
&format!(
"Knowledge system directly mutated health state: {:?}",
offenders
),
ctx,
);
}
}
Ok(())
}
fn load_knowledge_promotion_event_ids(
store_root: &Path,
) -> Result<HashSet<String>, error::DecapodError> {
let ledger = store_root.join("knowledge.promotions.jsonl");
if !ledger.exists() {
return Ok(HashSet::new());
}
let raw = fs::read_to_string(&ledger).map_err(error::DecapodError::IoError)?;
let mut ids = HashSet::new();
for (idx, line) in raw.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let v: serde_json::Value = serde_json::from_str(line).map_err(|e| {
error::DecapodError::ValidationError(format!(
"invalid promotion ledger line {} in {}: {}",
idx + 1,
ledger.display(),
e
))
})?;
if let Some(id) = v.get("event_id").and_then(|x| x.as_str()) {
ids.insert(id.to_string());
}
}
Ok(ids)
}
fn validate_lineage_hard_gate(
store: &Store,
ctx: &ValidationContext,
) -> Result<(), error::DecapodError> {
info("Lineage Hard Gate");
let todo_events = store.root.join("todo.events.jsonl");
let federation_db = store.root.join("federation.db");
let todo_db = store.root.join("todo.db");
if !todo_events.exists() || !federation_db.exists() || !todo_db.exists() {
skip("lineage inputs missing; skipping", ctx);
return Ok(());
}
if let Ok(metadata) = fs::metadata(&todo_events)
&& metadata.len() < 100
{
skip("todo.events.jsonl too small; skipping", ctx);
return Ok(());
}
let content = match fs::read_to_string(&todo_events) {
Ok(c) => c,
Err(_) => {
skip("cannot read todo.events.jsonl; skipping", ctx);
return Ok(());
}
};
if !content.contains("intent:") {
pass("no intent-tagged events found; skipping", ctx);
return Ok(());
}
let mut add_candidates = Vec::new();
let mut done_candidates = Vec::new();
for line in content.lines() {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let event_type = v.get("event_type").and_then(|x| x.as_str()).unwrap_or("");
let task_id = v.get("task_id").and_then(|x| x.as_str()).unwrap_or("");
if task_id.is_empty() {
continue;
}
let intent_ref = v
.get("payload")
.and_then(|p| p.get("intent_ref"))
.and_then(|x| x.as_str())
.unwrap_or("");
if !intent_ref.starts_with("intent:") {
continue;
}
if event_type == "task.add" {
add_candidates.push(task_id.to_string());
} else if event_type == "task.done" {
done_candidates.push(task_id.to_string());
}
}
if add_candidates.is_empty() && done_candidates.is_empty() {
pass("no intent-tagged task events to validate", ctx);
return Ok(());
}
let conn = db::db_connect_for_validate(&federation_db.to_string_lossy())?;
let todo_conn = db::db_connect_for_validate(&todo_db.to_string_lossy())?;
let mut violations = Vec::new();
for task_id in add_candidates {
let exists: i64 = todo_conn
.query_row(
"SELECT COUNT(*) FROM tasks WHERE id = ?1",
rusqlite::params![task_id.clone()],
|row| row.get(0),
)
.map_err(error::DecapodError::RusqliteError)?;
if exists == 0 {
continue;
}
let source = format!("event:{}", task_id);
let commitment_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM nodes n JOIN sources s ON s.node_id = n.id WHERE s.source = ?1 AND n.node_type = 'commitment'",
rusqlite::params![source],
|row| row.get(0),
)
.map_err(error::DecapodError::RusqliteError)?;
if commitment_count == 0 {
violations.push(format!(
"task.add {} missing commitment lineage node",
task_id
));
}
}
for task_id in done_candidates {
let exists: i64 = todo_conn
.query_row(
"SELECT COUNT(*) FROM tasks WHERE id = ?1",
rusqlite::params![task_id.clone()],
|row| row.get(0),
)
.map_err(error::DecapodError::RusqliteError)?;
if exists == 0 {
continue;
}
let source = format!("event:{}", task_id);
let commitment_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM nodes n JOIN sources s ON s.node_id = n.id WHERE s.source = ?1 AND n.node_type = 'commitment'",
rusqlite::params![source.clone()],
|row| row.get(0),
)
.map_err(error::DecapodError::RusqliteError)?;
let decision_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM nodes n JOIN sources s ON s.node_id = n.id WHERE s.source = ?1 AND n.node_type = 'decision'",
rusqlite::params![source],
|row| row.get(0),
)
.map_err(error::DecapodError::RusqliteError)?;
if commitment_count == 0 || decision_count == 0 {
violations.push(format!(
"task.done {} missing commitment/decision lineage nodes",
task_id
));
}
}
if violations.is_empty() {
pass(
"Intent-tagged task.add/task.done events have commitment+proof lineage",
ctx,
);
} else {
fail(&format!("Lineage gate violations: {:?}", violations), ctx);
}
Ok(())
}
fn validate_repomap_determinism(
ctx: &ValidationContext,
working_root: &Path,
) -> Result<(), error::DecapodError> {
info("Repo Map Determinism Gate");
use crate::core::repomap;
let dir1 = working_root.to_path_buf();
let dir2 = working_root.to_path_buf();
let h1 =
std::thread::spawn(move || serde_json::to_string(&repomap::generate_map(&dir1)).unwrap());
let h2 =
std::thread::spawn(move || serde_json::to_string(&repomap::generate_map(&dir2)).unwrap());
let m1 = h1
.join()
.map_err(|_| error::DecapodError::ValidationError("repomap thread panicked".into()))?;
let m2 = h2
.join()
.map_err(|_| error::DecapodError::ValidationError("repomap thread panicked".into()))?;
if m1 == m2 && !m1.is_empty() {
pass("Repo map output is deterministic", ctx);
} else {
fail("Repo map output is non-deterministic or empty", ctx);
}
Ok(())
}
fn validate_watcher_audit(
store: &Store,
ctx: &ValidationContext,
) -> Result<(), error::DecapodError> {
info("Watcher Audit Gate");
let audit_log = store.root.join("watcher.events.jsonl");
if audit_log.exists() {
pass("Watcher audit trail present", ctx);
} else {
warn(
"Watcher audit trail missing (run `decapod govern watcher run`)",
ctx,
);
}
Ok(())
}
fn validate_watcher_purity(
store: &Store,
ctx: &ValidationContext,
pre_read_broker: Option<&str>,
) -> Result<(), error::DecapodError> {
info("Watcher Purity Gate");
let fallback;
let content_opt = match pre_read_broker {
Some(c) => Some(c),
None => {
let audit_log = store.root.join("broker.events.jsonl");
if audit_log.exists() {
fallback = fs::read_to_string(audit_log)?;
Some(fallback.as_str())
} else {
None
}
}
};
if let Some(content) = content_opt {
let mut offenders = Vec::new();
for line in content.lines() {
if line.contains("\"actor\":\"watcher\"") {
offenders.push(line.to_string());
}
}
if offenders.is_empty() {
pass("Watcher purity verified (read-only checks only)", ctx);
} else {
fail(
&format!(
"Watcher subsystem attempted brokered mutations: {:?}",
offenders
),
ctx,
);
}
}
Ok(())
}
fn validate_archive_integrity(
store: &Store,
ctx: &ValidationContext,
) -> Result<(), error::DecapodError> {
info("Archive Integrity Gate");
let db_path = store.root.join("archive.db");
if !db_path.exists() {
skip("archive.db not found; skipping archive check", ctx);
return Ok(());
}
use crate::archive;
let failures = archive::verify_archives(store)?;
if failures.is_empty() {
pass(
"All session archives verified (content and hash match)",
ctx,
);
} else {
fail(
&format!("Archive integrity failures detected: {:?}", failures),
ctx,
);
}
Ok(())
}
fn validate_control_plane_contract(
store: &Store,
ctx: &ValidationContext,
) -> Result<(), error::DecapodError> {
info("Control Plane Contract Gate");
let data_dir = &store.root;
let mut violations = Vec::new();
let broker_log = data_dir.join("broker.events.jsonl");
if !broker_log.exists() {
pass("No broker events yet (first run)", ctx);
return Ok(());
}
let todo_db = data_dir.join("todo.db");
if todo_db.exists() {
let todo_events = data_dir.join("todo.events.jsonl");
if !todo_events.exists() {
violations.push("todo.db exists but todo.events.jsonl is missing".to_string());
}
}
let federation_db = data_dir.join("federation.db");
if federation_db.exists() {
let federation_events = data_dir.join("federation.events.jsonl");
if !federation_events.exists() {
violations
.push("federation.db exists but federation.events.jsonl is missing".to_string());
}
}
#[cfg(target_os = "linux")]
{
use std::process::Command;
if let Ok(output) = Command::new("timeout")
.args(["3s", "lsof", "+D", data_dir.to_string_lossy().as_ref()])
.output()
&& output.status.success()
{
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if line.contains("sqlite") && !line.contains("decapod") {
violations.push(format!("External SQLite process accessing store: {}", line));
}
}
}
}
if violations.is_empty() {
pass(
"Control plane contract honored (all mutations brokered)",
ctx,
);
} else {
fail(
&format!(
"Control plane contract violations detected: {:?}",
violations
),
ctx,
);
}
Ok(())
}
fn validate_canon_mutation(
store: &Store,
ctx: &ValidationContext,
pre_read_broker: Option<&str>,
) -> Result<(), error::DecapodError> {
info("Canon Mutation Gate");
let fallback;
let content_opt = match pre_read_broker {
Some(c) => Some(c),
None => {
let audit_log = store.root.join("broker.events.jsonl");
if audit_log.exists() {
fallback = fs::read_to_string(audit_log)?;
Some(fallback.as_str())
} else {
None
}
}
};
if let Some(content) = content_opt {
let mut offenders = Vec::new();
for line in content.lines() {
if line.contains("\"op\":\"write\"")
&& (line.contains(".md\"") || line.contains(".json\""))
&& !line.contains("\"actor\":\"decapod\"")
&& !line.contains("\"actor\":\"scaffold\"")
{
offenders.push(line.to_string());
}
}
if offenders.is_empty() {
pass("No unauthorized canon mutations detected", ctx);
} else {
warn(
&format!(
"Detected direct mutations to canonical documents: {:?}",
offenders
),
ctx,
);
}
}
Ok(())
}
fn validate_heartbeat_invocation_gate(
ctx: &ValidationContext,
working_root: &Path,
) -> Result<(), error::DecapodError> {
info("Heartbeat Invocation Gate");
let lib_rs = working_root.join("src").join("lib.rs");
let todo_rs = working_root.join("src").join("plugins").join("todo.rs");
if lib_rs.exists() && todo_rs.exists() {
let lib_content = fs::read_to_string(&lib_rs).unwrap_or_default();
let todo_content = fs::read_to_string(&todo_rs).unwrap_or_default();
let code_markers = [
(
lib_content.contains("should_auto_clock_in(&cli.command)")
&& lib_content.contains("todo::clock_in_agent_presence(&project_store)?"),
"Top-level command dispatch auto-clocks heartbeat",
),
(
lib_content
.contains("Command::Todo(todo_cli) => !todo::is_heartbeat_command(todo_cli)"),
"Decorator excludes explicit todo heartbeat to prevent duplicates",
),
(
todo_content.contains("pub fn clock_in_agent_presence")
&& todo_content.contains("record_heartbeat"),
"TODO plugin exposes reusable clock-in helper",
),
];
for (ok, msg) in code_markers {
if ok {
pass(msg, ctx);
} else {
fail(msg, ctx);
}
}
} else {
skip(
"Heartbeat wiring source files absent; skipping code-level heartbeat checks",
ctx,
);
}
let doc_markers = [
(
crate::core::assets::get_doc("core/DECAPOD")
.unwrap_or_default()
.contains("invocation heartbeat"),
"Router documents invocation heartbeat contract",
),
(
crate::core::assets::get_doc("interfaces/CONTROL_PLANE")
.unwrap_or_default()
.contains("invocation heartbeat"),
"Control-plane interface documents invocation heartbeat",
),
(
crate::core::assets::get_doc("plugins/TODO")
.unwrap_or_default()
.contains("auto-clocks liveness"),
"TODO plugin documents automatic liveness clock-in",
),
(
crate::core::assets::get_doc("plugins/REFLEX")
.unwrap_or_default()
.contains("todo.heartbeat.autoclaim"),
"REFLEX plugin documents heartbeat autoclaim action",
),
];
for (ok, msg) in doc_markers {
if ok {
pass(msg, ctx);
} else {
fail(msg, ctx);
}
}
Ok(())
}
fn validate_federation_gates(
store: &Store,
ctx: &ValidationContext,
) -> Result<(), error::DecapodError> {
info("Federation Gates");
let results = crate::plugins::federation::validate_federation(&store.root)?;
for (gate_name, passed, message) in results {
if passed {
pass(&format!("[{}] {}", gate_name, message), ctx);
} else {
warn(&format!("[{}] {}", gate_name, message), ctx);
}
}
Ok(())
}
fn validate_markdown_primitives_roundtrip_gate(
store: &Store,
ctx: &ValidationContext,
) -> Result<(), error::DecapodError> {
info("Markdown Primitive Round-Trip Gate");
match primitives::validate_roundtrip_gate(store) {
Ok(()) => {
pass(
"Markdown primitives export and round-trip validation pass",
ctx,
);
}
Err(err) => {
fail(
&format!("Markdown primitive round-trip failed: {}", err),
ctx,
);
}
}
Ok(())
}
fn validate_git_workspace_context(
ctx: &ValidationContext,
main_root: &Path,
repo_root: &Path,
) -> Result<(), error::DecapodError> {
info("Git Workspace Context Gate");
if std::env::var("DECAPOD_VALIDATE_SKIP_GIT_GATES").is_ok() {
skip(
"Git workspace gates skipped (DECAPOD_VALIDATE_SKIP_GIT_GATES set)",
ctx,
);
return Ok(());
}
let args: Vec<String> = std::env::args().collect();
let is_schema_command = args.iter().any(|a| {
a == "schema"
|| (a == "lcm"
&& args
.iter()
.skip_while(|x| *x != "lcm")
.nth(1)
.is_some_and(|x| x == "schema"))
|| (a == "map"
&& args
.iter()
.skip_while(|x| *x != "map")
.nth(1)
.is_some_and(|x| x == "schema"))
});
if is_schema_command {
skip(
"Schema command exempted from workspace requirement (read-only)",
ctx,
);
return Ok(());
}
if !is_inside_git_work_tree(repo_root) {
skip(
"Git workspace gates skipped: initialized project is not a git repository",
ctx,
);
return Ok(());
}
let is_worktree = crate::core::workspace::is_worktree(repo_root).unwrap_or(false);
let workspaces_path = main_root.join(".decapod").join("workspaces");
let is_isolated = is_worktree && repo_root.starts_with(&workspaces_path);
if is_isolated {
pass("Running in isolated workspace (.decapod/workspaces/)", ctx);
} else {
fail(
"Not running in isolated git worktree - must use .decapod/workspaces/ to prevent disrupting the root repository",
ctx,
);
}
let container_reasons = container_signal_reasons(repo_root);
let is_non_code = is_non_code_change(repo_root);
if is_container_workspaces_disabled(main_root) {
skip(
"Container workspace requirement disabled (container_workspaces = false in .decapod/config.toml). This is only safe for single-agent workflows; multi-agent concurrent runs require container isolation.",
ctx,
);
} else if !container_reasons.is_empty() {
pass(
&format!(
"Container-detected: (signals: {})",
container_reasons.join(", ")
),
ctx,
);
} else if is_non_code {
skip(
"Container workspace relaxation: only non-code files (docs, config, specs) are modified. Host-side execution is permitted for these tasks.",
ctx,
);
} else {
fail(
&auto_remediable_validation_message(
"container_workspace_required",
"Container workspace proof is still needed. This is expected on the host: Decapod can capture a coordination todo and prepare the isolated container workspace before final validation (claim.git.container_workspace_required)",
"Agent: run `decapod workspace ensure --container`; Decapod will ensure a coordination todo is captured, then enter the printed container command and rerun validation.",
),
ctx,
);
}
validate_commit_often_gate(ctx, repo_root)?;
Ok(())
}
fn is_non_code_change(repo_root: &Path) -> bool {
let output = std::process::Command::new("git")
.args(["status", "--porcelain"])
.current_dir(repo_root)
.output()
.ok();
let Some(output) = output else {
return false;
};
let stdout = String::from_utf8_lossy(&output.stdout);
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
if lines.is_empty() {
return true; }
lines.iter().all(|line| {
if line.len() < 4 {
return false;
}
let path = line[3..].trim();
path.starts_with("docs/")
|| path.ends_with(".md")
|| path == ".decapod/config.toml"
|| path == ".decapod/OVERRIDE.md"
|| path.starts_with(".decapod/generated/specs/")
|| path.starts_with(".decapod/generated/artifacts/")
|| path.starts_with(".decapod/contracts/")
})
}
fn validate_commit_often_gate(
ctx: &ValidationContext,
repo_root: &Path,
) -> Result<(), error::DecapodError> {
let max_dirty_files = std::env::var("DECAPOD_COMMIT_OFTEN_MAX_DIRTY_FILES")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.filter(|v| *v > 0)
.unwrap_or(6);
let status_output = std::process::Command::new("git")
.args(["status", "--porcelain"])
.current_dir(repo_root)
.output()
.map_err(error::DecapodError::IoError)?;
if !status_output.status.success() {
warn("Commit-often gate skipped: unable to read git status", ctx);
return Ok(());
}
let dirty_count = String::from_utf8_lossy(&status_output.stdout)
.lines()
.filter(|line| !line.trim().is_empty())
.count();
if dirty_count == 0 {
pass("Commit-often gate: working tree is clean", ctx);
return Ok(());
}
if dirty_count > max_dirty_files {
fail(
&format!(
"Commit-often mandate violation: {} dirty file(s) exceed limit {}. Commit incremental changes before continuing.",
dirty_count, max_dirty_files
),
ctx,
);
} else {
pass(
&format!(
"Commit-often gate: {} dirty file(s) within limit {}",
dirty_count, max_dirty_files
),
ctx,
);
}
Ok(())
}
fn validate_plan_governed_execution_gate(
store: &Store,
ctx: &ValidationContext,
main_root: &Path,
) -> Result<(), error::DecapodError> {
info("Plan-Governed Execution Gate");
if std::env::var("DECAPOD_VALIDATE_SKIP_GIT_GATES").is_ok() {
skip(
"Plan-governed execution gate skipped (DECAPOD_VALIDATE_SKIP_GIT_GATES set)",
ctx,
);
return Ok(());
}
let plan = plan_governance::load_plan(main_root)?;
if let Some(plan) = plan {
if plan.state != plan_governance::PlanState::Approved
&& plan.state != plan_governance::PlanState::Done
{
fail(
&format!(
"NEEDS_PLAN_APPROVAL: plan state is {:?}; execution/promotion requires APPROVED or DONE",
plan.state
),
ctx,
);
} else {
pass("Plan artifact state allows governed execution", ctx);
}
if plan.intent.trim().is_empty()
|| !plan.unknowns.is_empty()
|| !plan.human_questions.is_empty()
{
fail(
"NEEDS_HUMAN_INPUT: governed plan has unresolved intent/unknowns/questions",
ctx,
);
} else {
pass("Plan intent and unknowns are resolved", ctx);
}
} else {
let done_count = plan_governance::count_done_todos(&store.root)?;
if done_count > 0 {
fail(
&format!(
"NEEDS_PLAN_APPROVAL: {} done TODO(s) exist but governed PLAN artifact is missing",
done_count
),
ctx,
);
} else {
pass(
"No governed plan artifact present; gate is advisory until first done TODO",
ctx,
);
}
}
let unverified = plan_governance::collect_unverified_done_todos(&store.root)?;
if !unverified.is_empty() {
fail(
&format!(
"PROOF_HOOK_FAILED: {} done TODO(s) are CLAIMED but not VERIFIED: {}",
unverified.len(),
output::preview_messages(&unverified, 4, 80)
),
ctx,
);
} else {
pass("Done TODOs are proof-verified", ctx);
}
Ok(())
}
fn validate_git_protected_branch(
ctx: &ValidationContext,
repo_root: &Path,
) -> Result<(), error::DecapodError> {
info("Git Protected Branch Gate");
if std::env::var("DECAPOD_VALIDATE_SKIP_GIT_GATES").is_ok() {
skip(
"Git protected branch gate skipped (DECAPOD_VALIDATE_SKIP_GIT_GATES set)",
ctx,
);
return Ok(());
}
if !is_inside_git_work_tree(repo_root) {
skip(
"Git protected branch gate skipped: initialized project is not a git repository",
ctx,
);
return Ok(());
}
let current_branch = {
let output = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(repo_root)
.output();
output
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
})
.unwrap_or_else(|| "unknown".to_string())
};
let is_protected = is_protected_git_branch(¤t_branch);
if is_protected {
fail(
&format!(
"Currently on protected branch '{}' - implementation work must happen in working branch, not directly on protected refs (claim.git.no_direct_main_push)",
current_branch
),
ctx,
);
} else {
pass(
&format!("On working branch '{}' (not protected)", current_branch),
ctx,
);
}
if is_protected && git_origin_exists(repo_root) {
let ahead_behind = std::process::Command::new("git")
.args(["rev-list", "--left-right", "--count", "HEAD...origin/HEAD"])
.current_dir(repo_root)
.output();
if let Ok(out) = ahead_behind
&& out.status.success()
{
let counts = String::from_utf8_lossy(&out.stdout);
if let Some((ahead, _behind)) = parse_ahead_behind_counts(&counts) {
if ahead > 0 {
let output = std::process::Command::new("git")
.args(["rev-list", "--format=%s", "-n1", "HEAD"])
.current_dir(repo_root)
.output();
let commit_msg = output
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
})
.unwrap_or_else(|| "unknown".to_string());
fail(
&format!(
"Protected branch has {} unpushed commit(s) - direct push to protected branch detected (commit: {})",
ahead, commit_msg
),
ctx,
);
} else {
pass("No unpushed commits to protected branches", ctx);
}
}
}
} else if git_origin_exists(repo_root) {
match git_upstream_ref(repo_root) {
Some(upstream) => {
let output = std::process::Command::new("git")
.args(["rev-list", "--left-right", "--count"])
.arg(format!("HEAD...{}", upstream))
.current_dir(repo_root)
.output();
if let Ok(out) = output
&& out.status.success()
{
let counts = String::from_utf8_lossy(&out.stdout);
if let Some((ahead, behind)) = parse_ahead_behind_counts(&counts) {
pass(
&format!(
"Working branch divergence from upstream '{}': ahead {}, behind {}; protected branch direct-push check not applicable",
upstream, ahead, behind
),
ctx,
);
}
}
}
None => pass(
"Working branch has no upstream; protected branch direct-push check not applicable",
ctx,
),
}
}
Ok(())
}
fn is_protected_git_branch(branch: &str) -> bool {
matches!(branch, "master" | "main" | "production" | "stable") || branch.starts_with("release/")
}
fn git_origin_exists(repo_root: &Path) -> bool {
std::process::Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(repo_root)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn git_upstream_ref(repo_root: &Path) -> Option<String> {
let output = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
.current_dir(repo_root)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let upstream = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!upstream.is_empty()).then_some(upstream)
}
fn parse_ahead_behind_counts(counts: &str) -> Option<(u32, u32)> {
let mut parts = counts.split_whitespace();
let ahead = parts.next()?.parse().ok()?;
let behind = parts.next()?.parse().ok()?;
Some((ahead, behind))
}
fn validate_tooling_gate(
ctx: &ValidationContext,
repo_root: &Path,
) -> Result<(), error::DecapodError> {
info("Tooling Validation Gate");
let tooling_enabled = std::env::var("DECAPOD_VALIDATE_ENABLE_TOOLING_GATES")
.ok()
.map(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(false);
if !tooling_enabled {
skip(
"Tooling validation gates disabled by default (set DECAPOD_VALIDATE_ENABLE_TOOLING_GATES=1 to enable)",
ctx,
);
return Ok(());
}
if std::env::var("DECAPOD_VALIDATE_SKIP_TOOLING_GATES").is_ok() {
skip(
"Tooling validation gates skipped (DECAPOD_VALIDATE_SKIP_TOOLING_GATES set)",
ctx,
);
return Ok(());
}
let mut has_failures = false;
let mut has_tooling = false;
let cargo_toml = repo_root.join("Cargo.toml");
if cargo_toml.exists() {
has_tooling = true;
let fmt_available = cargo_subcommand_available("fmt");
let clippy_available = cargo_subcommand_available("clippy");
if !fmt_available {
fail(
&auto_remediable_validation_message(
"rustfmt_unavailable",
"Rust formatter is unavailable; `cargo fmt --version` did not succeed.",
"Agent: enter the repo's Nix/Rust toolchain with rustfmt available, then retry validation.",
),
ctx,
);
has_failures = true;
}
if !clippy_available {
fail(
&auto_remediable_validation_message(
"clippy_unavailable",
"Rust clippy is unavailable; `cargo clippy --version` did not succeed.",
"Agent: enter the repo's Nix/Rust toolchain with clippy available, then retry validation.",
),
ctx,
);
has_failures = true;
}
let fmt_handle = fmt_available.then(|| {
let root_fmt = repo_root.to_path_buf();
std::thread::spawn(move || {
std::process::Command::new("cargo")
.args(["fmt", "--all", "--", "--check"])
.current_dir(&root_fmt)
.output()
})
});
let clippy_handle = clippy_available.then(|| {
let root_clippy = repo_root.to_path_buf();
std::thread::spawn(move || {
std::process::Command::new("cargo")
.args([
"clippy",
"--all-targets",
"--all-features",
"--",
"-D",
"warnings",
])
.current_dir(&root_clippy)
.output()
})
});
if let Some(fmt_handle) = fmt_handle {
match fmt_handle.join().expect("fmt thread panicked") {
Ok(output) => {
if output.status.success() {
pass("Rust code formatting passes (cargo fmt)", ctx);
} else {
fail(
&auto_remediable_validation_message(
"cargo_fmt_failed",
&format!(
"Rust code formatting failed - run `cargo fmt --all`.\nstderr:\n{}",
String::from_utf8_lossy(&output.stderr).trim()
),
"Agent: run `cargo fmt --all`, then retry validation.",
),
ctx,
);
has_failures = true;
}
}
Err(e) => {
fail(
&auto_remediable_validation_message(
"cargo_fmt_execution_failed",
&format!("Failed to run cargo fmt: {}", e),
"Agent: switch to a complete Rust toolchain, then retry validation.",
),
ctx,
);
has_failures = true;
}
}
}
if let Some(clippy_handle) = clippy_handle {
match clippy_handle.join().expect("clippy thread panicked") {
Ok(output) => {
if output.status.success() {
pass("Rust linting passes (cargo clippy)", ctx);
} else {
fail(
&auto_remediable_validation_message(
"cargo_clippy_failed",
&format!(
"Rust linting failed - run `cargo clippy --all-targets --all-features`.\nstderr:\n{}",
String::from_utf8_lossy(&output.stderr).trim()
),
"Agent: fix lint failures; for local linker configuration, clear RUSTFLAGS and set CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=cc before retrying.",
),
ctx,
);
has_failures = true;
}
}
Err(e) => {
fail(
&auto_remediable_validation_message(
"cargo_clippy_execution_failed",
&format!("Failed to run cargo clippy: {}", e),
"Agent: switch to a complete Rust toolchain, then retry validation.",
),
ctx,
);
has_failures = true;
}
}
}
}
let pyproject = repo_root.join("pyproject.toml");
let requirements = repo_root.join("requirements.txt");
if pyproject.exists() || requirements.exists() {
has_tooling = true;
if std::process::Command::new("which")
.arg("ruff")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
let root_ruff = repo_root.to_path_buf();
let ruff_handle = std::thread::spawn(move || {
std::process::Command::new("ruff")
.args(["check", ".", "--output-format=concise"])
.current_dir(&root_ruff)
.output()
});
match ruff_handle.join().expect("ruff thread panicked") {
Ok(output) => {
if output.status.success() {
pass("Python linting passes (ruff)", ctx);
} else {
fail("Python linting failed - fix ruff violations", ctx);
has_failures = true;
}
}
Err(e) => {
warn(&format!("ruff not available: {}", e), ctx);
}
}
} else {
skip("ruff not installed; skipping Python linting", ctx);
}
}
let shell_check = repo_root.join(".shellcheckrc");
let shell_files_exist = std::fs::read_dir(repo_root)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.any(|e| {
let p = e.path();
p.is_file() && p.extension().map(|s| s == "sh").unwrap_or(false)
});
if shell_check.exists() || shell_files_exist {
has_tooling = true;
if std::process::Command::new("which")
.arg("shellcheck")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
let repo_root_clone = repo_root.to_path_buf();
let shellcheck_handle = std::thread::spawn(move || {
std::process::Command::new("shellcheck")
.args(["--enable=all"])
.current_dir(repo_root_clone)
.output()
});
match shellcheck_handle
.join()
.expect("shellcheck thread panicked")
{
Ok(output) => {
if output.status.success() {
pass("Shell script linting passes (shellcheck)", ctx);
} else {
fail(
"Shell script linting failed - fix shellcheck violations",
ctx,
);
has_failures = true;
}
}
Err(e) => {
warn(&format!("shellcheck failed: {}", e), ctx);
}
}
} else {
skip("shellcheck not installed; skipping shell linting", ctx);
}
}
let yaml_check = repo_root.join(".yamllint");
let yaml_files_exist = std::fs::read_dir(repo_root)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.any(|e| {
let p = e.path();
p.is_file()
&& p.extension()
.map(|s| s == "yaml" || s == "yml")
.unwrap_or(false)
});
if yaml_check.exists() || yaml_files_exist {
has_tooling = true;
if std::process::Command::new("which")
.arg("yamllint")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
let repo_root_clone = repo_root.to_path_buf();
let yamllint_handle = std::thread::spawn(move || {
std::process::Command::new("yamllint")
.arg(".")
.current_dir(repo_root_clone)
.output()
});
match yamllint_handle.join().expect("yamllint thread panicked") {
Ok(output) => {
if output.status.success() {
pass("YAML linting passes (yamllint)", ctx);
} else {
fail("YAML linting failed - fix yamllint violations", ctx);
has_failures = true;
}
}
Err(e) => {
warn(&format!("yamllint failed: {}", e), ctx);
}
}
} else {
skip("yamllint not installed; skipping YAML linting", ctx);
}
}
let dockerfile_exists = std::fs::read_dir(repo_root)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.any(|e| {
e.path()
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.to_lowercase() == "dockerfile")
.unwrap_or(false)
});
if dockerfile_exists {
has_tooling = true;
if std::process::Command::new("which")
.arg("hadolint")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
let repo_root_clone = repo_root.to_path_buf();
let hadolint_handle = std::thread::spawn(move || {
std::process::Command::new("hadolint")
.args(["Dockerfile"])
.current_dir(repo_root_clone)
.output()
});
match hadolint_handle.join().expect("hadolint thread panicked") {
Ok(output) => {
if output.status.success() {
pass("Dockerfile linting passes (hadolint)", ctx);
} else {
fail("Dockerfile linting failed - fix hadolint violations", ctx);
has_failures = true;
}
}
Err(e) => {
warn(&format!("hadolint failed: {}", e), ctx);
}
}
} else {
skip("hadolint not installed; skipping Dockerfile linting", ctx);
}
}
if !has_tooling {
skip(
"No recognized project files found; skipping tooling validation",
ctx,
);
} else if !has_failures {
pass(
"All toolchain validations pass - project is ready for promotion",
ctx,
);
}
Ok(())
}
fn cargo_subcommand_available(subcommand: &str) -> bool {
std::process::Command::new("cargo")
.arg(subcommand)
.arg("--version")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
fn validate_state_commit_gate(
ctx: &ValidationContext,
repo_root: &Path,
) -> Result<(), error::DecapodError> {
info("STATE_COMMIT Validation Gate");
let required_ci_job = std::env::var("DECAPOD_STATE_COMMIT_CI_JOB")
.unwrap_or_else(|_| "state_commit_golden_vectors".to_string());
info(&format!(
"STATE_COMMIT: required_ci_job = {}",
required_ci_job
));
let golden_v1_dir = repo_root
.join("tests")
.join("golden")
.join("state_commit")
.join("v1");
if !golden_v1_dir.exists() {
skip(
"No tests/golden/state_commit/v1 directory found; skipping STATE_COMMIT validation",
ctx,
);
return Ok(());
}
let required_files = ["scope_record_hash.txt", "state_commit_root.txt"];
let mut has_golden = true;
for file in &required_files {
if !golden_v1_dir.join(file).exists() {
fail(
&format!("Missing golden file: tests/golden/state_commit/v1/{}", file),
ctx,
);
has_golden = false;
}
}
if has_golden {
pass("STATE_COMMIT v1 golden vectors present", ctx);
let expected_scope_hash =
"41d7e3729b6f4512887fb3cb6f10140942b600041e0d88308b0177e06ebb4b93";
let expected_root = "28591ac86e52ffac76d5fc3aceeceda5d8592708a8d7fcb75371567fdc481492";
if let Ok(actual_hash) =
std::fs::read_to_string(golden_v1_dir.join("scope_record_hash.txt"))
&& actual_hash.trim() != expected_scope_hash
{
fail(
&format!(
"STATE_COMMIT v1 scope_record_hash changed! Expected {}, got {}. This requires a SPEC_VERSION bump to v2.",
expected_scope_hash,
actual_hash.trim()
),
ctx,
);
}
if let Ok(actual_root) =
std::fs::read_to_string(golden_v1_dir.join("state_commit_root.txt"))
&& actual_root.trim() != expected_root
{
fail(
&format!(
"STATE_COMMIT v1 state_commit_root changed! Expected {}, got {}. This requires a SPEC_VERSION bump to v2.",
expected_root,
actual_root.trim()
),
ctx,
);
}
}
Ok(())
}
fn validate_obligations(store: &Store, ctx: &ValidationContext) -> Result<(), error::DecapodError> {
crate::core::obligation::initialize_obligation_db(&store.root)?;
let obligations = crate::core::obligation::list_obligations(store)?;
let mut met_count = 0;
for ob in obligations {
if ob.status == crate::core::obligation::ObligationStatus::Met {
let (status, reason) = crate::core::obligation::verify_obligation(store, &ob.id)?;
if status != crate::core::obligation::ObligationStatus::Met {
fail(
&format!("Obligation {} failed verification: {}", ob.id, reason),
ctx,
);
} else {
met_count += 1;
}
}
}
pass(
&format!(
"Obligation Graph Validation Gate ({} met nodes verified)",
met_count
),
ctx,
);
Ok(())
}
fn validate_lcm_immutability(
store: &Store,
ctx: &ValidationContext,
) -> Result<(), error::DecapodError> {
info("LCM Immutability Gate");
let ledger_path = store.root.join(crate::core::schemas::LCM_EVENTS_NAME);
if !ledger_path.exists() {
pass("No LCM ledger yet; gate trivially passes", ctx);
return Ok(());
}
let failures = crate::plugins::lcm::validate_ledger_integrity(&store.root)?;
if failures.is_empty() {
pass("LCM ledger integrity verified", ctx);
} else {
for f in &failures {
fail(&format!("LCM immutability: {}", f), ctx);
}
}
Ok(())
}
fn validate_lcm_rebuild_gate(
store: &Store,
ctx: &ValidationContext,
) -> Result<(), error::DecapodError> {
info("LCM Rebuild Gate");
let ledger_path = store.root.join(crate::core::schemas::LCM_EVENTS_NAME);
if !ledger_path.exists() {
pass("No LCM ledger yet; rebuild gate trivially passes", ctx);
return Ok(());
}
let result = crate::plugins::lcm::rebuild_index(store, true)?;
if result.get("status").and_then(|v| v.as_str()) == Some("success") {
pass("LCM index rebuild successful", ctx);
} else {
let errors = result
.get("errors")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|e| e.as_str())
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
fail(&format!("LCM rebuild failed: {}", errors), ctx);
}
Ok(())
}
fn validate_gatekeeper_gate(
ctx: &ValidationContext,
working_root: &Path,
) -> Result<(), error::DecapodError> {
info("Gatekeeper Safety Gate");
let output = std::process::Command::new("git")
.args(["diff", "--cached", "--name-only"])
.current_dir(working_root)
.output();
let staged_paths: Vec<PathBuf> = match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(PathBuf::from)
.collect(),
_ => {
skip(
"Git not available or not in a repo; skipping gatekeeper gate",
ctx,
);
return Ok(());
}
};
if staged_paths.is_empty() {
pass("No staged files; gatekeeper gate trivially passes", ctx);
return Ok(());
}
let config = crate::core::gatekeeper::GatekeeperConfig::default();
let result = crate::core::gatekeeper::run_gatekeeper(working_root, &staged_paths, 0, &config)?;
if result.passed {
pass(
&format!(
"Gatekeeper: {} staged file(s) passed safety checks",
staged_paths.len()
),
ctx,
);
} else {
let secret_count = result
.violations
.iter()
.filter(|v| v.kind == crate::core::gatekeeper::ViolationKind::SecretDetected)
.count();
let blocked_count = result
.violations
.iter()
.filter(|v| v.kind == crate::core::gatekeeper::ViolationKind::PathBlocked)
.count();
let dangerous_count = result
.violations
.iter()
.filter(|v| v.kind == crate::core::gatekeeper::ViolationKind::DangerousPattern)
.count();
let mut parts = Vec::new();
if secret_count > 0 {
parts.push(format!("{} secret(s)", secret_count));
}
if blocked_count > 0 {
parts.push(format!("{} blocked path(s)", blocked_count));
}
if dangerous_count > 0 {
parts.push(format!("{} dangerous pattern(s)", dangerous_count));
}
fail(&format!("Gatekeeper violations: {}", parts.join(", ")), ctx);
}
Ok(())
}
pub fn evaluate_mandates(
project_root: &Path,
store: &Store,
mandates: &[crate::core::docs::Mandate],
) -> Vec<crate::core::rpc::Blocker> {
use crate::core::rpc::{Blocker, BlockerKind};
let mut blockers = Vec::new();
for mandate in mandates {
match mandate.check_tag.as_str() {
"gate.worktree.no_master" => {
let status = crate::core::workspace::get_workspace_status(project_root);
if let Ok(s) = status
&& s.git.is_protected
{
blockers.push(Blocker {
kind: BlockerKind::ProtectedBranch,
message: format!("Mandate Violation: {}", mandate.fragment.title),
resolve_hint: "Run `decapod workspace ensure` to create a working branch."
.to_string(),
});
}
}
"gate.worktree.isolated" => {
let status = crate::core::workspace::get_workspace_status(project_root);
if let Ok(s) = status
&& !s.git.in_worktree
{
blockers.push(Blocker {
kind: BlockerKind::WorkspaceRequired,
message: format!("Mandate Violation: {}", mandate.fragment.title),
resolve_hint:
"Run `decapod workspace ensure` to create an isolated git worktree."
.to_string(),
});
}
}
"gate.session.active" => {
}
"gate.todo.active_task" => {
let agent_id =
std::env::var("DECAPOD_AGENT_ID").unwrap_or_else(|_| "unknown".to_string());
if agent_id != "unknown" {
let mut active_tasks = crate::core::todo::list_tasks(
&store.root,
Some("open".to_string()),
None,
None,
None,
None,
);
if let Ok(ref mut tasks) = active_tasks {
let pre_filter_count = tasks.len();
let debug_info = if !tasks.is_empty() {
format!(
"First task assigned to: '{}', My ID: '{}'",
tasks[0].assigned_to, agent_id
)
} else {
format!(
"No tasks found. My ID: '{}', Root: '{}'",
agent_id,
project_root.display()
)
};
tasks.retain(|t| t.assigned_to == agent_id);
if tasks.is_empty() {
blockers.push(Blocker {
kind: BlockerKind::MissingProof,
message: format!("Mandate Violation: {} (Pre-filter: {}, {})", mandate.fragment.title, pre_filter_count, debug_info),
resolve_hint: "You MUST create and claim a `todo` before starting work. Run `decapod todo add \"...\"` then `decapod todo claim --id <id>`.".to_string(),
});
}
}
}
}
"gate.validation.pass" => {
}
_ => {}
}
}
blockers
}
fn validate_coplayer_policy_tightening(
ctx: &ValidationContext,
_working_root: &Path,
) -> Result<(), error::DecapodError> {
info("Co-Player Policy Tightening Gate");
use crate::core::coplayer::{CoPlayerSnapshot, derive_policy};
let profiles = vec![
("unknown", 0.0, 0),
("high", 0.5, 20),
("medium", 0.8, 20),
("low", 0.95, 100),
];
let mut prev_policy = None;
let mut all_valid = true;
for (risk, reliability, total) in &profiles {
let snap = CoPlayerSnapshot {
agent_id: format!("gate-test-{}", risk),
reliability_score: *reliability,
total_ops: *total,
successful_ops: (*total as f64 * reliability) as usize,
failed_ops: *total - (*total as f64 * reliability) as usize,
last_active: "gate-test".to_string(),
common_ops: vec![],
risk_profile: risk.to_string(),
};
let policy = derive_policy(&snap);
if !policy.require_validation {
fail(
&format!(
"Co-player policy for '{}' does not require validation (MUST always be true)",
risk
),
ctx,
);
all_valid = false;
}
if let Some(prev) = &prev_policy {
let prev: &crate::core::coplayer::CoPlayerPolicy = prev;
if policy.max_diff_lines < prev.max_diff_lines {
}
}
prev_policy = Some(policy);
}
if all_valid {
pass("Co-player policies only tighten constraints", ctx);
}
Ok(())
}
pub fn run_validation(
store: &Store,
main_root: &Path,
working_root: &Path,
_verbose: bool,
) -> Result<ValidationReport, error::DecapodError> {
let total_start = Instant::now();
let ctx = ValidationContext::new();
let broker_events_path = store.root.join("broker.events.jsonl");
let broker_content: Option<String> = if broker_events_path.exists() {
fs::read_to_string(&broker_events_path).ok()
} else {
None
};
match store.kind {
StoreKind::User => {
let start = Instant::now();
validate_user_store_blank_slate(&ctx)?;
let _ = start;
}
StoreKind::Repo => {
let start = Instant::now();
validate_repo_store_dogfood(store, &ctx, main_root)?;
let _ = start;
}
}
let timings: Mutex<Vec<(&str, Duration)>> = Mutex::new(Vec::new());
{
let _s = ();
let ctx = &ctx;
let timings = &timings;
let broker = broker_content.as_deref();
gate!(
s,
timings,
ctx,
"validate_repo_map",
validate_repo_map(ctx, working_root)
);
gate!(
s,
timings,
ctx,
"validate_no_legacy_namespaces",
validate_no_legacy_namespaces(ctx, working_root)
);
gate!(
s,
timings,
ctx,
"validate_embedded_self_contained",
validate_embedded_self_contained(ctx, working_root)
);
gate!(
s,
timings,
ctx,
"validate_docs_templates_bucket",
validate_docs_templates_bucket(ctx, main_root)
);
gate!(
s,
timings,
ctx,
"validate_entrypoint_invariants",
validate_entrypoint_invariants(ctx, working_root)
);
gate!(
s,
timings,
ctx,
"validate_interface_contract_bootstrap",
validate_interface_contract_bootstrap(ctx, main_root)
);
gate!(
s,
timings,
ctx,
"validate_health_purity",
validate_health_purity(ctx, main_root)
);
gate!(
s,
timings,
ctx,
"validate_project_scoped_state",
validate_project_scoped_state(store, ctx, main_root)
);
gate!(
s,
timings,
ctx,
"validate_generated_artifact_whitelist",
validate_generated_artifact_whitelist(store, ctx, main_root)
);
gate!(
s,
timings,
ctx,
"validate_project_config_toml",
validate_project_config_toml(ctx, working_root)
);
gate!(
s,
timings,
ctx,
"validate_project_specs_docs",
validate_project_specs_docs(ctx, working_root)
);
gate!(
s,
timings,
ctx,
"validate_spec_drift",
validate_spec_drift(ctx, working_root)
);
gate!(
s,
timings,
ctx,
"validate_machine_contract",
validate_machine_contract(ctx, working_root)
);
gate!(
s,
timings,
ctx,
"validate_workunit_manifests_if_present",
validate_workunit_manifests_if_present(ctx, main_root)
);
gate!(
s,
timings,
ctx,
"validate_recursive_improvement_passes_if_present",
validate_recursive_improvement_passes_if_present(ctx, main_root)
);
gate!(
s,
timings,
ctx,
"validate_context_capsule_policy_contract",
validate_context_capsule_policy_contract(ctx, working_root)
);
gate!(
s,
timings,
ctx,
"validate_context_capsules_if_present",
validate_context_capsules_if_present(ctx, main_root)
);
gate!(
s,
timings,
ctx,
"validate_knowledge_promotions_if_present",
validate_knowledge_promotions_if_present(ctx, main_root)
);
gate!(
s,
timings,
ctx,
"validate_skill_cards_if_present",
validate_skill_cards_if_present(ctx, main_root)
);
gate!(
s,
timings,
ctx,
"validate_skill_resolutions_if_present",
validate_skill_resolutions_if_present(ctx, main_root)
);
gate!(
s,
timings,
ctx,
"validate_internalization_artifacts_if_present",
validate_internalization_artifacts_if_present(ctx, main_root)
);
gate!(
s,
timings,
ctx,
"validate_eval_gate_if_required",
validate_eval_gate_if_required(store, ctx)
);
gate!(
s,
timings,
ctx,
"validate_schema_determinism",
validate_schema_determinism(ctx, working_root)
);
gate!(
s,
timings,
ctx,
"validate_database_schema_versions",
validate_database_schema_versions(store, ctx)
);
gate!(
s,
timings,
ctx,
"validate_health_cache_integrity",
validate_health_cache_integrity(store, ctx)
);
gate!(
s,
timings,
ctx,
"validate_risk_map",
validate_risk_map(store, ctx)
);
gate!(
s,
timings,
ctx,
"validate_risk_map_violations",
validate_risk_map_violations(store, ctx, broker)
);
gate!(
s,
timings,
ctx,
"validate_policy_integrity",
validate_policy_integrity(store, ctx, broker)
);
gate!(
s,
timings,
ctx,
"validate_knowledge_integrity",
validate_knowledge_integrity(store, ctx, broker)
);
gate!(
s,
timings,
ctx,
"validate_lineage_hard_gate",
validate_lineage_hard_gate(store, ctx)
);
gate!(
s,
timings,
ctx,
"validate_repomap_determinism",
validate_repomap_determinism(ctx, working_root)
);
gate!(
s,
timings,
ctx,
"validate_watcher_audit",
validate_watcher_audit(store, ctx)
);
gate!(
s,
timings,
ctx,
"validate_watcher_purity",
validate_watcher_purity(store, ctx, broker)
);
gate!(
s,
timings,
ctx,
"validate_archive_integrity",
validate_archive_integrity(store, ctx)
);
gate!(
s,
timings,
ctx,
"validate_control_plane_contract",
validate_control_plane_contract(store, ctx)
);
gate!(
s,
timings,
ctx,
"validate_canon_mutation",
validate_canon_mutation(store, ctx, broker)
);
gate!(
s,
timings,
ctx,
"validate_heartbeat_invocation_gate",
validate_heartbeat_invocation_gate(ctx, working_root)
);
gate!(
s,
timings,
ctx,
"validate_markdown_primitives_roundtrip_gate",
validate_markdown_primitives_roundtrip_gate(store, ctx)
);
gate!(
s,
timings,
ctx,
"validate_federation_gates",
validate_federation_gates(store, ctx)
);
gate!(
s,
timings,
ctx,
"validate_git_workspace_context",
validate_git_workspace_context(ctx, main_root, working_root)
);
gate!(
s,
timings,
ctx,
"validate_git_protected_branch",
validate_git_protected_branch(ctx, working_root)
);
gate!(
s,
timings,
ctx,
"validate_tooling_gate",
validate_tooling_gate(ctx, working_root)
);
gate!(
s,
timings,
ctx,
"validate_state_commit_gate",
validate_state_commit_gate(ctx, working_root)
);
gate!(
s,
timings,
ctx,
"validate_obligations",
validate_obligations(store, ctx)
);
gate!(
s,
timings,
ctx,
"validate_gatekeeper_gate",
validate_gatekeeper_gate(ctx, working_root)
);
gate!(
s,
timings,
ctx,
"validate_coplayer_policy_tightening",
validate_coplayer_policy_tightening(ctx, working_root)
);
gate!(
s,
timings,
ctx,
"validate_lcm_immutability",
validate_lcm_immutability(store, ctx)
);
gate!(
s,
timings,
ctx,
"validate_lcm_rebuild_gate",
validate_lcm_rebuild_gate(store, ctx)
);
gate!(
s,
timings,
ctx,
"validate_plan_governed_execution_gate",
validate_plan_governed_execution_gate(store, ctx, main_root)
);
}
let elapsed = total_start.elapsed();
let pass_count = ctx.pass_count.load(Ordering::Relaxed);
let fail_count = ctx.fail_count.load(Ordering::Relaxed);
let warn_count = ctx.warn_count.load(Ordering::Relaxed);
let fails = ctx.fails.lock().unwrap().clone();
let warns = ctx.warns.lock().unwrap().clone();
let fail_total = (fails.len() as u32).max(fail_count);
let warn_total = (warns.len() as u32).max(warn_count);
let mut gate_timings = timings.into_inner().unwrap();
gate_timings.sort_by(|a, b| b.1.cmp(&a.1));
Ok(ValidationReport {
status: if fail_total > 0 { "fail" } else { "ok" }.to_string(),
elapsed_ms: elapsed.as_millis() as u64,
pass_count,
fail_count: fail_total,
warn_count: warn_total,
failures: fails,
warnings: warns,
gate_timings: gate_timings
.into_iter()
.map(|(name, elapsed)| ValidationGateTiming {
name: name.to_string(),
elapsed_ms: elapsed.as_millis() as u64,
})
.collect(),
})
}
pub fn render_validation_report(report: &ValidationReport, verbose: bool) {
use crate::core::ansi::AnsiExt;
let intent_content = crate::core::assets::get_doc("specs/INTENT").unwrap_or_default();
let intent_version =
extract_md_version(&intent_content).unwrap_or_else(|| "unknown".to_string());
println!(
"{} {}",
"▶".bright_green().bold(),
"validate".bright_cyan().bold()
);
println!(
" {} intent_version={}",
"spec".bright_cyan(),
intent_version.bright_white()
);
println!(
" {} {}",
"gate".bright_magenta().bold(),
"Four Invariants Gate".bright_white()
);
if verbose {
println!(
" {} {}",
"gates".bright_magenta().bold(),
"timings".bright_white()
);
for gate in &report.gate_timings {
println!(
" {} [{}] {}ms",
"✓".bright_green(),
gate.name.bright_cyan(),
gate.elapsed_ms
);
}
}
println!(
" {} pass={} fail={} warn={} ({:.2}s)",
"summary".bright_cyan().bold(),
report.pass_count.to_string().bright_green(),
report.fail_count.to_string().bright_red(),
report.warn_count.to_string().bright_yellow(),
report.elapsed_ms as f64 / 1000.0
);
if !report.failures.is_empty() && verbose {
println!(
" {} {}",
"issues".bright_red().bold(),
output::preview_messages(&report.failures, 10, 160)
);
} else if let Some(first_failure) = report.failures.first() {
println!(
" {} {} found; first item: {}",
"issues".bright_yellow().bold(),
report.failures.len().to_string().bright_red(),
output::compact_line(first_failure, 120)
);
println!(
" {} run `decapod validate -v` or `decapod validate --format json` for the full list",
"details".bright_blue().bold()
);
}
if !report.warnings.is_empty() && verbose {
println!(
" {} {}",
"warnings".bright_yellow().bold(),
output::preview_messages(&report.warnings, 10, 160)
);
} else if !report.warnings.is_empty() {
println!(
" {} {} hidden; use `-v` for warning details",
"warnings".bright_yellow().bold(),
report.warnings.len().to_string().bright_yellow()
);
}
if report.fail_count == 0 {
println!(
"{} {}",
"✓".bright_green().bold(),
"validation passed".bright_green().bold()
);
} else {
println!(
"{} {}",
"!".bright_yellow().bold(),
"validation needs attention".bright_yellow().bold()
);
}
}
#[cfg(test)]
mod tests {
use super::{is_protected_git_branch, parse_ahead_behind_counts};
#[test]
fn protected_branch_matching_is_limited_to_protected_refs() {
assert!(is_protected_git_branch("master"));
assert!(is_protected_git_branch("main"));
assert!(is_protected_git_branch("release/2026.05"));
assert!(!is_protected_git_branch("agent/codex/fix"));
assert!(!is_protected_git_branch("feature/main-cleanup"));
}
#[test]
fn parses_git_ahead_behind_counts() {
assert_eq!(parse_ahead_behind_counts("3\t1\n"), Some((3, 1)));
assert_eq!(parse_ahead_behind_counts("0 12"), Some((0, 12)));
assert_eq!(parse_ahead_behind_counts("bad 12"), None);
}
}