Skip to main content

a3s_code_core/
verification.rs

1//! Verification contracts for A3S Code 2.0.
2//!
3//! Verification is represented as structured checks and reports. The first
4//! stage is intentionally conservative: required checks start as
5//! `needs_review` until a verifier or the harness marks them passed/failed.
6
7use crate::program::ProgramVerificationHint;
8use anyhow::Result;
9use serde::{Deserialize, Serialize};
10use std::path::{Path, PathBuf};
11
12pub const VERIFICATION_REPORT_SCHEMA: &str = "a3s.verification_report.v1";
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum VerificationStatus {
17    Passed,
18    Failed,
19    NeedsReview,
20    Skipped,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct VerificationCheck {
25    pub id: String,
26    pub kind: String,
27    pub description: String,
28    pub status: VerificationStatus,
29    #[serde(default)]
30    pub required: bool,
31    #[serde(default, skip_serializing_if = "Vec::is_empty")]
32    pub suggested_tools: Vec<String>,
33    #[serde(default, skip_serializing_if = "Vec::is_empty")]
34    pub evidence_uris: Vec<String>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub residual_risk: Option<String>,
37}
38
39impl VerificationCheck {
40    pub fn required(
41        id: impl Into<String>,
42        kind: impl Into<String>,
43        description: impl Into<String>,
44    ) -> Self {
45        Self {
46            id: id.into(),
47            kind: kind.into(),
48            description: description.into(),
49            status: VerificationStatus::NeedsReview,
50            required: true,
51            suggested_tools: Vec::new(),
52            evidence_uris: Vec::new(),
53            residual_risk: None,
54        }
55    }
56
57    pub fn optional(
58        id: impl Into<String>,
59        kind: impl Into<String>,
60        description: impl Into<String>,
61    ) -> Self {
62        Self {
63            required: false,
64            ..Self::required(id, kind, description)
65        }
66    }
67
68    pub fn with_status(mut self, status: VerificationStatus) -> Self {
69        self.status = status;
70        self
71    }
72
73    pub fn with_suggested_tools(
74        mut self,
75        tools: impl IntoIterator<Item = impl Into<String>>,
76    ) -> Self {
77        self.suggested_tools = tools.into_iter().map(Into::into).collect();
78        self
79    }
80
81    pub fn with_evidence_uris(mut self, uris: impl IntoIterator<Item = impl Into<String>>) -> Self {
82        self.evidence_uris = uris.into_iter().map(Into::into).collect();
83        self
84    }
85
86    pub fn with_residual_risk(mut self, risk: impl Into<String>) -> Self {
87        self.residual_risk = Some(risk.into());
88        self
89    }
90
91    pub fn from_program_hint(subject: &str, index: usize, hint: &ProgramVerificationHint) -> Self {
92        let id = format!("program:{subject}:{}:{index}", hint.kind);
93        let check = if hint.required {
94            Self::required(id, hint.kind.clone(), hint.message.clone())
95        } else {
96            Self::optional(id, hint.kind.clone(), hint.message.clone())
97        };
98
99        check
100            .with_suggested_tools(hint.suggested_tools.clone())
101            .with_evidence_uris(hint.evidence_uris.clone())
102    }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct VerificationCommand {
107    pub id: String,
108    pub kind: String,
109    pub description: String,
110    pub command: String,
111    #[serde(default)]
112    pub required: bool,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub timeout_ms: Option<u64>,
115    /// Expected process exit code (ACCEPTANCE `expect:exit=N`; presets use 0).
116    #[serde(default)]
117    pub expect_exit: i32,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct VerificationPreset {
122    pub id: String,
123    pub project_kind: String,
124    pub description: String,
125    pub commands: Vec<VerificationCommand>,
126}
127
128impl VerificationPreset {
129    pub fn new(
130        id: impl Into<String>,
131        project_kind: impl Into<String>,
132        description: impl Into<String>,
133        commands: Vec<VerificationCommand>,
134    ) -> Self {
135        Self {
136            id: id.into(),
137            project_kind: project_kind.into(),
138            description: description.into(),
139            commands,
140        }
141    }
142}
143
144impl VerificationCommand {
145    pub fn required(
146        id: impl Into<String>,
147        kind: impl Into<String>,
148        description: impl Into<String>,
149        command: impl Into<String>,
150    ) -> Self {
151        Self {
152            id: id.into(),
153            kind: kind.into(),
154            description: description.into(),
155            command: command.into(),
156            required: true,
157            timeout_ms: None,
158            expect_exit: 0,
159        }
160    }
161
162    pub fn optional(
163        id: impl Into<String>,
164        kind: impl Into<String>,
165        description: impl Into<String>,
166        command: impl Into<String>,
167    ) -> Self {
168        Self {
169            required: false,
170            ..Self::required(id, kind, description, command)
171        }
172    }
173
174    pub fn with_expect_exit(mut self, expect_exit: i32) -> Self {
175        self.expect_exit = expect_exit;
176        self
177    }
178
179    pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self {
180        self.timeout_ms = Some(timeout_ms);
181        self
182    }
183
184    pub fn to_check(&self) -> VerificationCheck {
185        let check = if self.required {
186            VerificationCheck::required(
187                self.id.clone(),
188                self.kind.clone(),
189                self.description.clone(),
190            )
191        } else {
192            VerificationCheck::optional(
193                self.id.clone(),
194                self.kind.clone(),
195                self.description.clone(),
196            )
197        };
198
199        check.with_suggested_tools(["bash"])
200    }
201
202    pub fn check_from_execution(
203        &self,
204        exit_code: i32,
205        metadata: Option<&serde_json::Value>,
206        execution_error: Option<&str>,
207    ) -> VerificationCheck {
208        let passed = exit_code == self.expect_exit && execution_error.is_none();
209        let mut check = self.to_check().with_status(if passed {
210            VerificationStatus::Passed
211        } else {
212            VerificationStatus::Failed
213        });
214
215        let evidence_uris = artifact_uris(metadata);
216        if !evidence_uris.is_empty() {
217            check = check.with_evidence_uris(evidence_uris);
218        }
219
220        if let Some(error) = execution_error {
221            return check
222                .with_residual_risk(format!("verification command could not run: {error}"));
223        }
224
225        if exit_code != self.expect_exit {
226            check = check.with_residual_risk(format!(
227                "verification command exited with code {exit_code}, expected {}: {}",
228                self.expect_exit, self.command
229            ));
230        }
231
232        check
233    }
234}
235
236pub fn verification_presets_for_workspace(workspace: impl AsRef<Path>) -> Vec<VerificationPreset> {
237    let workspace = workspace.as_ref();
238    let mut presets = Vec::new();
239
240    if workspace.join("Cargo.toml").is_file() {
241        presets.push(VerificationPreset::new(
242            "rust-default",
243            "rust",
244            "Rust cargo verification",
245            vec![
246                VerificationCommand::required(
247                    "rust:fmt",
248                    "format",
249                    "Check Rust formatting",
250                    "cargo fmt -- --check",
251                ),
252                VerificationCommand::required(
253                    "rust:check",
254                    "type_check",
255                    "Run Rust type checking",
256                    "cargo check",
257                ),
258                VerificationCommand::required("rust:test", "test", "Run Rust tests", "cargo test"),
259                VerificationCommand::optional(
260                    "rust:clippy",
261                    "lint",
262                    "Run Rust clippy lints",
263                    "cargo clippy -- -D warnings",
264                ),
265            ],
266        ));
267    }
268
269    if workspace.join("package.json").is_file() {
270        if let Some(preset) = node_verification_preset(workspace) {
271            presets.push(preset);
272        }
273    }
274
275    if workspace.join("pyproject.toml").is_file() || workspace.join("pytest.ini").is_file() {
276        let mut commands = Vec::new();
277        if workspace.join("tests").is_dir()
278            || file_contains(&workspace.join("pyproject.toml"), "[tool.pytest")
279            || workspace.join("pytest.ini").is_file()
280        {
281            commands.push(VerificationCommand::required(
282                "python:test",
283                "test",
284                "Run Python tests",
285                "python -m pytest",
286            ));
287        }
288        if workspace.join("ruff.toml").is_file()
289            || workspace.join(".ruff.toml").is_file()
290            || file_contains(&workspace.join("pyproject.toml"), "[tool.ruff")
291        {
292            commands.push(VerificationCommand::optional(
293                "python:ruff",
294                "lint",
295                "Run Ruff lint checks",
296                "python -m ruff check .",
297            ));
298        }
299        if workspace.join("mypy.ini").is_file()
300            || workspace.join(".mypy.ini").is_file()
301            || file_contains(&workspace.join("pyproject.toml"), "[tool.mypy")
302        {
303            commands.push(VerificationCommand::optional(
304                "python:mypy",
305                "type_check",
306                "Run mypy type checking",
307                "python -m mypy .",
308            ));
309        }
310        if !commands.is_empty() {
311            presets.push(VerificationPreset::new(
312                "python-default",
313                "python",
314                "Python project verification",
315                commands,
316            ));
317        }
318    }
319
320    if workspace.join("go.mod").is_file() {
321        presets.push(VerificationPreset::new(
322            "go-default",
323            "go",
324            "Go module verification",
325            vec![
326                VerificationCommand::required("go:test", "test", "Run Go tests", "go test ./..."),
327                VerificationCommand::optional("go:vet", "lint", "Run go vet", "go vet ./..."),
328            ],
329        ));
330    }
331
332    presets
333}
334
335fn node_verification_preset(workspace: &Path) -> Option<VerificationPreset> {
336    let package_json = std::fs::read_to_string(workspace.join("package.json")).ok()?;
337    let package: serde_json::Value = serde_json::from_str(&package_json).ok()?;
338    let scripts = package.get("scripts").and_then(|value| value.as_object())?;
339    let package_manager = detect_node_package_manager(workspace, &package);
340    let mut commands = Vec::new();
341
342    for (script, kind, description, required) in [
343        ("test", "test", "Run JavaScript tests", true),
344        (
345            "typecheck",
346            "type_check",
347            "Run JavaScript type checks",
348            false,
349        ),
350        ("lint", "lint", "Run JavaScript lint checks", false),
351    ] {
352        if scripts.contains_key(script) {
353            let command = node_script_command(&package_manager, script);
354            let id = format!("node:{script}");
355            let verification = if required {
356                VerificationCommand::required(id, kind, description, command)
357            } else {
358                VerificationCommand::optional(id, kind, description, command)
359            };
360            commands.push(verification);
361        }
362    }
363
364    if commands.is_empty() {
365        return None;
366    }
367
368    Some(VerificationPreset::new(
369        "node-default",
370        "node",
371        "Node.js package verification",
372        commands,
373    ))
374}
375
376fn detect_node_package_manager(workspace: &Path, package: &serde_json::Value) -> String {
377    if let Some(manager) = package
378        .get("packageManager")
379        .and_then(|value| value.as_str())
380    {
381        if let Some((name, _)) = manager.split_once('@') {
382            return name.to_string();
383        }
384    }
385
386    if workspace.join("pnpm-lock.yaml").is_file() {
387        "pnpm".to_string()
388    } else if workspace.join("yarn.lock").is_file() {
389        "yarn".to_string()
390    } else if workspace.join("bun.lockb").is_file() || workspace.join("bun.lock").is_file() {
391        "bun".to_string()
392    } else {
393        "npm".to_string()
394    }
395}
396
397fn node_script_command(package_manager: &str, script: &str) -> String {
398    match package_manager {
399        "pnpm" | "yarn" => format!("{package_manager} {script}"),
400        "bun" => format!("bun run {script}"),
401        "npm" if script == "test" => "npm test".to_string(),
402        "npm" => format!("npm run {script}"),
403        other => format!("{other} run {script}"),
404    }
405}
406
407fn file_contains(path: &Path, needle: &str) -> bool {
408    std::fs::read_to_string(path)
409        .map(|content| content.contains(needle))
410        .unwrap_or(false)
411}
412
413#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
414pub struct VerificationReport {
415    pub schema: String,
416    pub subject: String,
417    pub status: VerificationStatus,
418    pub checks: Vec<VerificationCheck>,
419    #[serde(default, skip_serializing_if = "Vec::is_empty")]
420    pub residual_risks: Vec<String>,
421    /// Effect digest this report is allowed to close. Unbound reports never pass the completion gate.
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub effect_digest: Option<String>,
424}
425
426impl VerificationReport {
427    pub fn new(subject: impl Into<String>, checks: Vec<VerificationCheck>) -> Self {
428        let mut report = Self {
429            schema: VERIFICATION_REPORT_SCHEMA.to_string(),
430            subject: subject.into(),
431            status: VerificationStatus::Skipped,
432            checks,
433            residual_risks: Vec::new(),
434            effect_digest: None,
435        };
436        report.status = report.derive_status();
437        report
438    }
439
440    pub fn from_program_hints(subject: &str, hints: &[ProgramVerificationHint]) -> Self {
441        let checks = hints
442            .iter()
443            .enumerate()
444            .map(|(index, hint)| VerificationCheck::from_program_hint(subject, index, hint))
445            .collect();
446        Self::new(format!("program:{subject}"), checks)
447    }
448
449    pub fn with_effect_digest(mut self, digest: impl Into<String>) -> Self {
450        self.effect_digest = Some(digest.into());
451        self
452    }
453
454    pub fn with_residual_risk(mut self, risk: impl Into<String>) -> Self {
455        self.residual_risks.push(risk.into());
456        self.status = self.derive_status();
457        self
458    }
459
460    pub fn is_complete(&self) -> bool {
461        !matches!(self.status, VerificationStatus::NeedsReview)
462    }
463
464    pub fn to_value(&self) -> serde_json::Value {
465        serde_json::to_value(self).unwrap_or_else(|_| {
466            serde_json::json!({
467                "schema": VERIFICATION_REPORT_SCHEMA,
468                "subject": self.subject,
469                "status": "failed",
470                "checks": [],
471                "residual_risks": ["failed to serialize verification report"],
472            })
473        })
474    }
475
476    fn derive_status(&self) -> VerificationStatus {
477        if self
478            .checks
479            .iter()
480            .any(|check| check.status == VerificationStatus::Failed)
481        {
482            return VerificationStatus::Failed;
483        }
484
485        if self.checks.iter().any(|check| {
486            check.required
487                && matches!(
488                    check.status,
489                    VerificationStatus::NeedsReview | VerificationStatus::Skipped
490                )
491        }) {
492            return VerificationStatus::NeedsReview;
493        }
494
495        if !self.residual_risks.is_empty() {
496            return VerificationStatus::NeedsReview;
497        }
498
499        if self.checks.is_empty() {
500            VerificationStatus::Skipped
501        } else {
502            VerificationStatus::Passed
503        }
504    }
505}
506
507fn artifact_uris(metadata: Option<&serde_json::Value>) -> Vec<String> {
508    let mut uris = Vec::new();
509    if let Some(metadata) = metadata {
510        collect_artifact_uris(metadata, &mut uris);
511    }
512    uris.sort();
513    uris.dedup();
514    uris
515}
516
517fn collect_artifact_uris(value: &serde_json::Value, uris: &mut Vec<String>) {
518    match value {
519        serde_json::Value::Object(object) => {
520            if let Some(uri) = object.get("artifact_uri").and_then(|value| value.as_str()) {
521                uris.push(uri.to_string());
522            }
523            for value in object.values() {
524                collect_artifact_uris(value, uris);
525            }
526        }
527        serde_json::Value::Array(items) => {
528            for value in items {
529                collect_artifact_uris(value, uris);
530            }
531        }
532        _ => {}
533    }
534}
535
536#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
537pub struct VerificationSummary {
538    pub status: VerificationStatus,
539    pub report_count: usize,
540    pub required_check_count: usize,
541    pub pending_required_check_count: usize,
542    pub failed_check_count: usize,
543    pub residual_risk_count: usize,
544    #[serde(default, skip_serializing_if = "Vec::is_empty")]
545    pub pending_subjects: Vec<String>,
546    #[serde(default, skip_serializing_if = "Vec::is_empty")]
547    pub failed_subjects: Vec<String>,
548}
549
550impl VerificationSummary {
551    pub fn from_reports(reports: &[VerificationReport]) -> Self {
552        let mut required_check_count = 0;
553        let mut pending_required_check_count = 0;
554        let mut failed_check_count = 0;
555        let mut residual_risk_count = 0;
556        let mut pending_subjects = Vec::new();
557        let mut failed_subjects = Vec::new();
558
559        for report in reports {
560            if matches!(report.status, VerificationStatus::NeedsReview) {
561                pending_subjects.push(report.subject.clone());
562            }
563
564            if matches!(report.status, VerificationStatus::Failed) {
565                failed_subjects.push(report.subject.clone());
566            }
567
568            residual_risk_count += report.residual_risks.len();
569
570            for check in &report.checks {
571                if check.required {
572                    required_check_count += 1;
573                    if matches!(
574                        check.status,
575                        VerificationStatus::NeedsReview | VerificationStatus::Skipped
576                    ) {
577                        pending_required_check_count += 1;
578                        pending_subjects.push(report.subject.clone());
579                    }
580                }
581
582                if check.status == VerificationStatus::Failed {
583                    failed_check_count += 1;
584                    failed_subjects.push(report.subject.clone());
585                }
586
587                if check.residual_risk.is_some() {
588                    residual_risk_count += 1;
589                    pending_subjects.push(report.subject.clone());
590                }
591            }
592        }
593
594        pending_subjects.sort();
595        pending_subjects.dedup();
596        failed_subjects.sort();
597        failed_subjects.dedup();
598
599        let status = if failed_check_count > 0
600            || reports
601                .iter()
602                .any(|report| report.status == VerificationStatus::Failed)
603        {
604            VerificationStatus::Failed
605        } else if pending_required_check_count > 0
606            || residual_risk_count > 0
607            || reports
608                .iter()
609                .any(|report| report.status == VerificationStatus::NeedsReview)
610        {
611            VerificationStatus::NeedsReview
612        } else if reports.is_empty() {
613            VerificationStatus::Skipped
614        } else {
615            VerificationStatus::Passed
616        };
617
618        Self {
619            status,
620            report_count: reports.len(),
621            required_check_count,
622            pending_required_check_count,
623            failed_check_count,
624            residual_risk_count,
625            pending_subjects,
626            failed_subjects,
627        }
628    }
629
630    pub fn is_complete(&self) -> bool {
631        !matches!(self.status, VerificationStatus::NeedsReview)
632    }
633
634    /// Whether structured verification is strong enough to authorize `GoalAchieved`.
635    ///
636    /// Fail-closed: empty reports, skipped/optional-only passes, pending required
637    /// checks, failures, and residual risks never authorize completion by themselves.
638    /// An LLM may still evaluate prose, but the host/core gate requires this.
639    pub fn supports_goal_achievement(&self) -> bool {
640        matches!(self.status, VerificationStatus::Passed)
641            && self.report_count > 0
642            && self.required_check_count > 0
643            && self.pending_required_check_count == 0
644            && self.failed_check_count == 0
645            && self.residual_risk_count == 0
646    }
647
648    pub fn to_value(&self) -> serde_json::Value {
649        serde_json::to_value(self).unwrap_or_else(|_| {
650            serde_json::json!({
651                "status": "failed",
652                "report_count": self.report_count,
653                "required_check_count": self.required_check_count,
654                "pending_required_check_count": self.pending_required_check_count,
655                "failed_check_count": self.failed_check_count,
656                "residual_risk_count": self.residual_risk_count,
657                "failed_subjects": ["failed to serialize verification summary"],
658            })
659        })
660    }
661}
662
663/// Normalize shell text for preset command coverage checks.
664pub fn normalize_shell_command(command: &str) -> String {
665    command.split_whitespace().collect::<Vec<_>>().join(" ")
666}
667
668/// True when `command` executes `preset` (exact, with trailing args, or after `&&` / `;`).
669pub fn shell_command_covers_preset(command: &str, preset: &str) -> bool {
670    let command = normalize_shell_command(command);
671    let preset = normalize_shell_command(preset);
672    if command.is_empty() || preset.is_empty() {
673        return false;
674    }
675    if command == preset || command.starts_with(&format!("{preset} ")) {
676        return true;
677    }
678    let and_prefix = format!("&& {preset}");
679    let semi_prefix = format!("; {preset}");
680    command.contains(&format!("{and_prefix} "))
681        || command.ends_with(&and_prefix)
682        || command.contains(&format!("{semi_prefix} "))
683        || command.ends_with(&semi_prefix)
684}
685
686/// Build a verification report when a shell command covers a workspace preset
687/// command **or** a durable `/goal` ACCEPTANCE.md machine criterion
688/// (`kind:command` or synthesized `test -f` for `kind:file_exists`).
689pub fn shell_verification_report_for_command(
690    workspace: &Path,
691    command: &str,
692    exit_code: i32,
693    metadata: Option<&serde_json::Value>,
694    execution_error: Option<&str>,
695) -> Option<VerificationReport> {
696    let matched = verification_presets_for_workspace(workspace)
697        .into_iter()
698        .flat_map(|preset| preset.commands)
699        .chain(acceptance_shell_commands_for_workspace(workspace))
700        .find(|preset_command| shell_command_covers_preset(command, &preset_command.command))?;
701    let check = matched.check_from_execution(exit_code, metadata, execution_error);
702    Some(VerificationReport::new(
703        format!("shell:{}", matched.id),
704        vec![check],
705    ))
706}
707
708/// Loop STATE statuses that still own a live `/goal` ACCEPTANCE contract.
709///
710/// Completed (`verified` / `achieved` / `cancelled`) loops must not pollute
711/// shell evidence or GoalAchieved emission for a later goal in the same workspace.
712const ACTIVE_GOAL_LOOP_STATUSES: &[&str] = &["running", "retrying", "paused"];
713
714/// True when `STATE.md` marks this loop as still owning durable ACCEPTANCE.
715fn loop_owns_active_acceptance_contract(loop_dir: &Path) -> bool {
716    let Ok(state) = std::fs::read_to_string(loop_dir.join("STATE.md")) else {
717        return false;
718    };
719    for line in state.lines() {
720        let trimmed = line.trim();
721        let Some(status) = trimmed.strip_prefix("Status:") else {
722            continue;
723        };
724        let status = status.trim();
725        return ACTIVE_GOAL_LOOP_STATUSES
726            .iter()
727            .any(|allowed| status.eq_ignore_ascii_case(allowed));
728    }
729    false
730}
731
732/// Parse machine ACCEPTANCE criteria from **active** `.a3s/loops/*/ACCEPTANCE.md`
733/// so Core shell evidence and Host ACCEPTANCE re-checks share the same predicates.
734///
735/// Only loops whose `STATE.md` is `running`, `retrying`, or `paused` contribute.
736/// Stale completed loops are ignored (avoids leftover `assert:true` authorizing
737/// a later goal).
738///
739/// - `kind:command` → the assert command (with optional `expect:exit`)
740/// - `kind:file_exists` → synthesized `test -f <path>` (exit 0 == exists)
741pub fn acceptance_shell_commands_for_workspace(
742    workspace: impl AsRef<Path>,
743) -> Vec<VerificationCommand> {
744    let loops = workspace.as_ref().join(".a3s").join("loops");
745    let Ok(entries) = std::fs::read_dir(&loops) else {
746        return Vec::new();
747    };
748    let mut commands = Vec::new();
749    for entry in entries.flatten() {
750        let path = entry.path();
751        if !path.is_dir() {
752            continue;
753        }
754        if !loop_owns_active_acceptance_contract(&path) {
755            continue;
756        }
757        let acceptance = path.join("ACCEPTANCE.md");
758        let Ok(body) = std::fs::read_to_string(&acceptance) else {
759            continue;
760        };
761        let loop_id = path
762            .file_name()
763            .and_then(|name| name.to_str())
764            .unwrap_or("goal");
765        commands.extend(parse_acceptance_shell_commands(
766            &body,
767            loop_id,
768            workspace.as_ref(),
769        ));
770    }
771    commands
772}
773
774fn parse_acceptance_shell_commands(
775    body: &str,
776    loop_id: &str,
777    workspace: &Path,
778) -> Vec<VerificationCommand> {
779    let mut commands = Vec::new();
780    for (index, line) in body.lines().enumerate() {
781        let trimmed = line.trim_start();
782        let rest = if let Some(rest) = trimmed.strip_prefix("- [") {
783            rest
784        } else if let Some(rest) = trimmed.strip_prefix("* [") {
785            rest
786        } else {
787            continue;
788        };
789        let Some((_mark, body)) = rest.split_once(']') else {
790            continue;
791        };
792        let body = body.trim().trim_start_matches(':').trim();
793        let lower = body.to_ascii_lowercase();
794        let line_id = format!("acceptance:{loop_id}:{}", index + 1);
795        if lower.starts_with("kind:command") {
796            let Some(command) = extract_acceptance_assert_command(body) else {
797                continue;
798            };
799            let expect_exit = extract_acceptance_expect_exit(body).unwrap_or(0);
800            commands.push(
801                VerificationCommand::required(
802                    line_id,
803                    "acceptance_command",
804                    format!("ACCEPTANCE kind:command ({loop_id})"),
805                    command,
806                )
807                .with_expect_exit(expect_exit),
808            );
809            continue;
810        }
811        if lower.starts_with("kind:file_exists") || lower.starts_with("kind:file-exists") {
812            let Some(path) = extract_acceptance_assert_command(body) else {
813                continue;
814            };
815            // Skip workspace-escaping paths so Core evidence matches Host latch
816            // (durable goals prove in-workspace outcomes only).
817            if !acceptance_file_path_allowed_in_workspace(workspace, &path) {
818                continue;
819            }
820            let command = format!("test -f {}", shell_quote_acceptance_path(&path));
821            commands.push(VerificationCommand::required(
822                line_id,
823                "acceptance_file_exists",
824                format!("ACCEPTANCE kind:file_exists ({loop_id})"),
825                command,
826            ));
827        }
828    }
829    commands
830}
831
832/// Whether a `kind:file_exists` assert may contribute Core shell evidence.
833///
834/// Relative `../` escapes are rejected. Absolute paths are accepted only when
835/// they canonicalize to a regular file under the workspace (otherwise Host
836/// latch is the authority and Core must not treat them as machine evidence).
837fn acceptance_file_path_allowed_in_workspace(workspace: &Path, path: &str) -> bool {
838    if path.is_empty() {
839        return false;
840    }
841    let p = Path::new(path);
842    if !p.is_absolute() {
843        let mut depth = 0i32;
844        for component in p.components() {
845            match component {
846                std::path::Component::ParentDir => {
847                    depth -= 1;
848                    if depth < 0 {
849                        return false;
850                    }
851                }
852                std::path::Component::Normal(_) => depth += 1,
853                std::path::Component::RootDir | std::path::Component::Prefix(_) => return false,
854                std::path::Component::CurDir => {}
855            }
856        }
857        return true;
858    }
859    let Ok(workspace_canon) = workspace.canonicalize() else {
860        return false;
861    };
862    let candidate = PathBuf::from(path);
863    if !candidate.is_file() {
864        return false;
865    }
866    let Ok(file_canon) = candidate.canonicalize() else {
867        return false;
868    };
869    file_canon.starts_with(&workspace_canon)
870}
871
872/// Quote a path for a synthesized `test -f` ACCEPTANCE predicate.
873fn shell_quote_acceptance_path(path: &str) -> String {
874    if path.is_empty() {
875        return "''".to_string();
876    }
877    if path
878        .chars()
879        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-'))
880    {
881        return path.to_string();
882    }
883    format!("'{}'", path.replace('\'', "'\\''"))
884}
885
886fn extract_acceptance_assert_command(body: &str) -> Option<String> {
887    let idx = body.to_ascii_lowercase().find("assert:")?;
888    let after = body[idx + "assert:".len()..].trim_start();
889    if let Some(rest) = after.strip_prefix('`') {
890        let end = rest.find('`')?;
891        let command = rest[..end].trim();
892        if command.is_empty() {
893            return None;
894        }
895        return Some(command.to_string());
896    }
897    let command = after
898        .split_whitespace()
899        .next()
900        .filter(|token| !token.to_ascii_lowercase().starts_with("expect:"))?;
901    Some(command.to_string())
902}
903
904fn extract_acceptance_expect_exit(body: &str) -> Option<i32> {
905    let lower = body.to_ascii_lowercase();
906    let idx = lower.find("expect:exit=")?;
907    let after = &body[idx + "expect:exit=".len()..];
908    let digits: String = after
909        .chars()
910        .take_while(|c| c.is_ascii_digit() || *c == '-')
911        .collect();
912    digits.parse().ok()
913}
914
915/// Merge a shell-preset verification report into tool metadata when applicable.
916pub fn merge_shell_verification_metadata(
917    metadata: Option<serde_json::Value>,
918    workspace: Option<&Path>,
919    command: &str,
920    exit_code: i32,
921    execution_error: Option<&str>,
922) -> Option<serde_json::Value> {
923    let mut metadata = metadata.unwrap_or_else(|| serde_json::json!({}));
924    if let Some(object) = metadata.as_object_mut() {
925        object.insert(
926            "verification_shell_command".to_string(),
927            serde_json::Value::String(command.to_string()),
928        );
929    }
930    let Some(workspace) = workspace else {
931        return Some(metadata);
932    };
933    if metadata.get("verification_report").is_some() {
934        return Some(metadata);
935    }
936    let Some(report) = shell_verification_report_for_command(
937        workspace,
938        command,
939        exit_code,
940        Some(&metadata),
941        execution_error,
942    ) else {
943        return Some(metadata);
944    };
945    if let Some(object) = metadata.as_object_mut() {
946        object.insert("verification_report".to_string(), report.to_value());
947    }
948    Some(metadata)
949}
950
951/// One shell segment that is only an existence check, plus the path it names.
952#[derive(Debug, Clone, PartialEq, Eq)]
953pub(crate) struct ExistenceCheck {
954    pub segment: String,
955    pub path: String,
956}
957
958/// Existence checks inside a command.
959///
960/// A segment must itself be `test -f`, `test -e`, `[ -f ]`, `[ -e ]`, or
961/// `[[ -f ]]` / `[[ -e ]]`. `&&`, `||`, `;`, and newlines separate segments, so
962/// a `cd` prefix or a trailing `echo` still counts. A command that merely
963/// mentions those words (`cargo test`, `echo test -f file`, `true`) does not.
964pub(crate) fn existence_checks(command: &str) -> Vec<ExistenceCheck> {
965    shell_segments(command)
966        .into_iter()
967        .filter_map(|segment| {
968            let path = path_from_single_existence_check(segment)?;
969            Some(ExistenceCheck {
970                segment: segment.to_string(),
971                path,
972            })
973        })
974        .collect()
975}
976
977/// Extract the path from the first existence-check segment.
978///
979/// Accepts `test -f PATH`, `test -e PATH`, `[ -f PATH ]`, `[ -e PATH ]`, and
980/// the same checks inside a compound command.
981pub fn path_from_existence_check_command(command: &str) -> Option<String> {
982    existence_checks(command)
983        .into_iter()
984        .next()
985        .map(|check| check.path)
986}
987
988fn path_from_single_existence_check(command: &str) -> Option<String> {
989    let trimmed = command.trim();
990    let trimmed = trimmed
991        .strip_prefix("command ")
992        .map(str::trim_start)
993        .unwrap_or(trimmed);
994    if let Some(rest) = trimmed.strip_prefix("[[") {
995        let rest = rest.trim_start();
996        let rest = rest
997            .strip_prefix("-f")
998            .or_else(|| rest.strip_prefix("-e"))?
999            .trim_start();
1000        let rest = rest.trim_end().strip_suffix("]]")?.trim();
1001        return unquote_shell_token(rest);
1002    }
1003    let rest = if let Some(rest) = trimmed.strip_prefix("test") {
1004        let rest = rest.trim_start();
1005        let rest = rest
1006            .strip_prefix("-f")
1007            .or_else(|| rest.strip_prefix("-e"))?
1008            .trim_start();
1009        rest
1010    } else {
1011        let rest = trimmed.strip_prefix('[')?;
1012        let rest = rest.trim_start();
1013        let rest = rest
1014            .strip_prefix("-f")
1015            .or_else(|| rest.strip_prefix("-e"))?
1016            .trim_start();
1017        rest.trim_end()
1018            .trim_end_matches(']')
1019            .trim_end()
1020            .trim_start()
1021    };
1022    unquote_shell_token(rest)
1023}
1024
1025/// Split on unquoted `&&`, `||`, `;`, and newlines. Quotes stay intact so a
1026/// path that contains those characters is not a separator.
1027fn shell_segments(command: &str) -> Vec<&str> {
1028    let mut segments = Vec::new();
1029    let mut start = 0;
1030    let mut quote: Option<char> = None;
1031    let chars: Vec<(usize, char)> = command.char_indices().collect();
1032    let mut index = 0;
1033    while index < chars.len() {
1034        let (byte, ch) = chars[index];
1035        if let Some(open) = quote {
1036            if ch == open {
1037                quote = None;
1038            }
1039            index += 1;
1040            continue;
1041        }
1042        if ch == '\'' || ch == '"' {
1043            quote = Some(ch);
1044            index += 1;
1045            continue;
1046        }
1047        let doubled = index + 1 < chars.len()
1048            && ((ch == '&' && chars[index + 1].1 == '&')
1049                || (ch == '|' && chars[index + 1].1 == '|'));
1050        if doubled || ch == ';' || ch == '\n' {
1051            let segment = command[start..byte].trim();
1052            if !segment.is_empty() {
1053                segments.push(segment);
1054            }
1055            index += if doubled { 2 } else { 1 };
1056            start = chars
1057                .get(index)
1058                .map(|(next, _)| *next)
1059                .unwrap_or(command.len());
1060            continue;
1061        }
1062        index += 1;
1063    }
1064    let tail = command[start..].trim();
1065    if !tail.is_empty() {
1066        segments.push(tail);
1067    }
1068    segments
1069}
1070
1071fn unquote_shell_token(token: &str) -> Option<String> {
1072    let token = token.trim();
1073    if token.is_empty() {
1074        return None;
1075    }
1076    if let Some(inner) = token.strip_prefix('\'') {
1077        let end = inner.find('\'')?;
1078        return Some(inner[..end].replace("'\\''", "'"));
1079    }
1080    if let Some(inner) = token.strip_prefix('"') {
1081        let end = inner.find('"')?;
1082        return Some(inner[..end].to_string());
1083    }
1084    Some(token.split_whitespace().next()?.to_string())
1085}
1086
1087/// Path-boundary match for mutation verification (not basename-only).
1088pub(crate) fn mutation_path_matches(mutated: &str, verified: &str) -> bool {
1089    let mutated = mutated.trim_start_matches("./");
1090    let verified = verified.trim_start_matches("./");
1091    if mutated.is_empty() || verified.is_empty() {
1092        return false;
1093    }
1094    if mutated == verified {
1095        return true;
1096    }
1097    // Require a path-boundary suffix match. Basename-only equality across
1098    // different directories would overfit the gate to false verification.
1099    mutated.ends_with(&format!("/{verified}")) || verified.ends_with(&format!("/{mutated}"))
1100}
1101
1102fn report_is_required_pass(report: &VerificationReport) -> bool {
1103    matches!(report.status, VerificationStatus::Passed)
1104        && report.checks.iter().any(|check| check.required)
1105        && report
1106            .checks
1107            .iter()
1108            .filter(|check| check.required)
1109            .all(|check| matches!(check.status, VerificationStatus::Passed))
1110}
1111
1112/// When a Host shell check proves a mutated path still exists (`test -f` /
1113/// ACCEPTANCE `kind:file_exists` synthesized to `test -f`), bind the current
1114/// mutation ledger digest so the completion gate can Allow(Verified). Bare
1115/// `true` / unrelated presets do not bind — that would overfit the gate.
1116///
1117/// Prefer [`bind_host_shell_reports_to_mutations_with_content`] for write-backed
1118/// mutations so wrong on-disk bytes cannot Verify.
1119pub fn bind_host_shell_reports_to_mutations(
1120    reports: &mut [VerificationReport],
1121    shell_command: Option<&str>,
1122    mutation_paths: &[String],
1123    effect_digest: &str,
1124) {
1125    bind_host_shell_reports_to_mutations_with_content(
1126        reports,
1127        shell_command,
1128        mutation_paths,
1129        effect_digest,
1130        None,
1131    );
1132}
1133
1134/// Like [`bind_host_shell_reports_to_mutations`], optionally requiring
1135/// `content_match = Some((expected_ledger_digest, on_disk_digest))`.
1136pub fn bind_host_shell_reports_to_mutations_with_content(
1137    reports: &mut [VerificationReport],
1138    shell_command: Option<&str>,
1139    mutation_paths: &[String],
1140    effect_digest: &str,
1141    content_match: Option<(&str, &str)>,
1142) {
1143    if mutation_paths.is_empty() || effect_digest.trim().is_empty() {
1144        return;
1145    }
1146    let Some(path) = path_from_existence_check_command(shell_command.unwrap_or("")) else {
1147        return;
1148    };
1149    if !mutation_paths
1150        .iter()
1151        .any(|mutated| mutation_path_matches(mutated, &path))
1152    {
1153        return;
1154    }
1155    if let Some((expected, on_disk)) = content_match {
1156        if expected.trim().is_empty() || expected != on_disk {
1157            return;
1158        }
1159    }
1160    for report in reports.iter_mut() {
1161        if report.effect_digest.is_some() {
1162            continue;
1163        }
1164        if !report_is_required_pass(report) {
1165            continue;
1166        }
1167        report.effect_digest = Some(effect_digest.to_string());
1168    }
1169}
1170
1171/// Synthesize a Host Passed report when bash successfully runs `test -f` /
1172/// `test -e` on a path that is already on the mutation ledger.
1173///
1174/// Existence-only form for ACCEPTANCE predicates. Write-backed mutations should
1175/// use [`host_report_for_verified_mutation_path_with_content`].
1176pub fn host_report_for_verified_mutation_path(
1177    command: &str,
1178    exit_code: i32,
1179    mutation_paths: &[String],
1180    effect_digest: &str,
1181) -> Option<VerificationReport> {
1182    host_report_for_verified_mutation_path_with_content(
1183        command,
1184        exit_code,
1185        mutation_paths,
1186        effect_digest,
1187        None,
1188    )
1189}
1190
1191/// Like [`host_report_for_verified_mutation_path`] with optional content match.
1192pub fn host_report_for_verified_mutation_path_with_content(
1193    command: &str,
1194    exit_code: i32,
1195    mutation_paths: &[String],
1196    effect_digest: &str,
1197    content_match: Option<(&str, &str)>,
1198) -> Option<VerificationReport> {
1199    if exit_code != 0 || effect_digest.trim().is_empty() {
1200        return None;
1201    }
1202    let path = path_from_existence_check_command(command)?;
1203    if !mutation_paths
1204        .iter()
1205        .any(|mutated| mutation_path_matches(mutated, &path))
1206    {
1207        return None;
1208    }
1209    if let Some((expected, on_disk)) = content_match {
1210        if expected.trim().is_empty() || expected != on_disk {
1211            return None;
1212        }
1213    }
1214    let description = if content_match.is_some() {
1215        format!("Host verified mutated path exists with matching content digest: {path}")
1216    } else {
1217        format!("Host verified mutated path still exists: {path}")
1218    };
1219    Some(
1220        VerificationReport::new(
1221            format!("shell:mutation_path_verify:{path}"),
1222            vec![VerificationCheck::required(
1223                format!("mutation_path_verify:{path}"),
1224                "mutation_path_verify",
1225                description,
1226            )
1227            .with_status(VerificationStatus::Passed)],
1228        )
1229        .with_effect_digest(effect_digest),
1230    )
1231}
1232
1233/// Combine an LLM achievement judgment with structured verification evidence.
1234pub fn goal_achieved_after_evidence_gate(
1235    llm_achieved: bool,
1236    reports: &[VerificationReport],
1237) -> bool {
1238    llm_achieved && VerificationSummary::from_reports(reports).supports_goal_achievement()
1239}
1240
1241/// True when a shell verification subject was derived from ACCEPTANCE.md.
1242pub fn is_acceptance_verification_subject(subject: &str) -> bool {
1243    subject.starts_with("shell:acceptance:")
1244}
1245
1246/// True when reports include at least one passing ACCEPTANCE-derived shell report.
1247pub fn reports_include_passing_acceptance(reports: &[VerificationReport]) -> bool {
1248    reports.iter().any(|report| {
1249        is_acceptance_verification_subject(&report.subject)
1250            && matches!(report.status, VerificationStatus::Passed)
1251            && report
1252                .checks
1253                .iter()
1254                .any(|check| check.required && matches!(check.status, VerificationStatus::Passed))
1255    })
1256}
1257
1258fn reports_include_passing_active_acceptance(
1259    reports: &[VerificationReport],
1260    acceptance_commands: &[VerificationCommand],
1261) -> bool {
1262    // Group machine criteria by loop id. Every active loop that still owns
1263    // ACCEPTANCE must have its own passing report — a sibling/orphaned loop's
1264    // easy criterion must not authorize GoalAchieved for a different loop.
1265    let mut subjects_by_loop: std::collections::HashMap<String, std::collections::HashSet<String>> =
1266        std::collections::HashMap::new();
1267    for command in acceptance_commands {
1268        let Some(loop_id) = acceptance_loop_id_from_command_id(&command.id) else {
1269            continue;
1270        };
1271        subjects_by_loop
1272            .entry(loop_id.to_string())
1273            .or_default()
1274            .insert(format!("shell:{}", command.id));
1275    }
1276    if subjects_by_loop.is_empty() {
1277        return false;
1278    }
1279    subjects_by_loop.values().all(|subjects| {
1280        reports.iter().any(|report| {
1281            subjects.contains(&report.subject)
1282                && matches!(report.status, VerificationStatus::Passed)
1283                && report.checks.iter().any(|check| {
1284                    check.required && matches!(check.status, VerificationStatus::Passed)
1285                })
1286        })
1287    })
1288}
1289
1290/// Command ids look like `acceptance:<loop_id>:<line>` (loop ids may contain `-`).
1291fn acceptance_loop_id_from_command_id(command_id: &str) -> Option<&str> {
1292    let rest = command_id.strip_prefix("acceptance:")?;
1293    let (loop_id, _line) = rest.rsplit_once(':')?;
1294    if loop_id.is_empty() {
1295        return None;
1296    }
1297    Some(loop_id)
1298}
1299
1300/// Decide whether planning should emit `GoalAchieved` before `End`.
1301///
1302/// When the workspace declares durable `/goal` machine ACCEPTANCE criteria on an
1303/// **active** loop, emission requires a passing report for **each** such loop's
1304/// criteria — so a workspace preset alone, a completed-loop leftover, or a
1305/// sibling/orphaned active loop's report cannot authorize GoalAchieved.
1306/// Host latch still re-checks the current loop ACCEPTANCE.
1307pub fn should_emit_goal_achieved_for_workspace(
1308    llm_achieved: bool,
1309    reports: &[VerificationReport],
1310    workspace: Option<&Path>,
1311) -> bool {
1312    if !goal_achieved_after_evidence_gate(llm_achieved, reports) {
1313        return false;
1314    }
1315    let Some(workspace) = workspace else {
1316        return true;
1317    };
1318    let acceptance = acceptance_shell_commands_for_workspace(workspace);
1319    if acceptance.is_empty() {
1320        return true;
1321    }
1322    reports_include_passing_active_acceptance(reports, &acceptance)
1323}
1324
1325/// Decide whether planning should emit `GoalAchieved` before `End`.
1326///
1327/// Kept as a thin wrapper so host/core share one fail-closed contract and tests
1328/// can pin emission policy without standing up a full agent loop.
1329pub fn should_emit_goal_achieved(llm_achieved: bool, reports: &[VerificationReport]) -> bool {
1330    should_emit_goal_achieved_for_workspace(llm_achieved, reports, None)
1331}
1332
1333pub fn format_verification_summary(summary: &VerificationSummary) -> String {
1334    let reports = plural(summary.report_count, "report", "reports");
1335    let required_checks = plural(
1336        summary.required_check_count,
1337        "required check",
1338        "required checks",
1339    );
1340
1341    let mut text = match summary.status {
1342        VerificationStatus::Skipped if summary.report_count == 0 => {
1343            "Verification skipped: no reports.".to_string()
1344        }
1345        VerificationStatus::Skipped => format!("Verification skipped: {reports}."),
1346        VerificationStatus::Passed => {
1347            format!("Verification passed: {reports}, {required_checks}.")
1348        }
1349        VerificationStatus::Failed => {
1350            let failed = if summary.failed_check_count > 0 {
1351                plural(summary.failed_check_count, "failed check", "failed checks")
1352            } else {
1353                "failed report".to_string()
1354            };
1355            let subjects = subject_list(&summary.failed_subjects);
1356            if subjects.is_empty() {
1357                format!("Verification failed: {failed}. {reports}, {required_checks}.")
1358            } else {
1359                format!(
1360                    "Verification failed: {failed} across subjects: {subjects}. {reports}, {required_checks}."
1361                )
1362            }
1363        }
1364        VerificationStatus::NeedsReview => {
1365            let pending = if summary.pending_required_check_count > 0 {
1366                plural(
1367                    summary.pending_required_check_count,
1368                    "pending required check",
1369                    "pending required checks",
1370                )
1371            } else {
1372                "review required".to_string()
1373            };
1374            let subjects = subject_list(&summary.pending_subjects);
1375            if subjects.is_empty() {
1376                format!("Verification needs review: {pending}. {reports}, {required_checks}.")
1377            } else {
1378                format!(
1379                    "Verification needs review: {pending} across subjects: {subjects}. {reports}, {required_checks}."
1380                )
1381            }
1382        }
1383    };
1384
1385    if summary.residual_risk_count > 0 {
1386        text.push(' ');
1387        text.push_str(&format!("Residual risks: {}.", summary.residual_risk_count));
1388    }
1389
1390    text
1391}
1392
1393pub fn verification_status_label(status: VerificationStatus) -> &'static str {
1394    match status {
1395        VerificationStatus::Passed => "passed",
1396        VerificationStatus::Failed => "failed",
1397        VerificationStatus::NeedsReview => "needs_review",
1398        VerificationStatus::Skipped => "skipped",
1399    }
1400}
1401
1402fn plural(count: usize, singular: &str, plural: &str) -> String {
1403    if count == 1 {
1404        format!("1 {singular}")
1405    } else {
1406        format!("{count} {plural}")
1407    }
1408}
1409
1410fn subject_list(subjects: &[String]) -> String {
1411    const MAX_SUBJECTS: usize = 5;
1412    let mut visible: Vec<&str> = subjects
1413        .iter()
1414        .take(MAX_SUBJECTS)
1415        .map(String::as_str)
1416        .collect();
1417    if subjects.len() > MAX_SUBJECTS {
1418        visible.push("...");
1419    }
1420    visible.join(", ")
1421}
1422
1423pub trait Verifier: Send + Sync {
1424    fn verify(&self, checks: Vec<VerificationCheck>) -> Result<VerificationReport>;
1425}
1426
1427#[derive(Debug, Clone)]
1428pub struct StaticVerifier {
1429    subject: String,
1430}
1431
1432impl StaticVerifier {
1433    pub fn new(subject: impl Into<String>) -> Self {
1434        Self {
1435            subject: subject.into(),
1436        }
1437    }
1438}
1439
1440impl Verifier for StaticVerifier {
1441    fn verify(&self, checks: Vec<VerificationCheck>) -> Result<VerificationReport> {
1442        Ok(VerificationReport::new(self.subject.clone(), checks))
1443    }
1444}
1445
1446#[cfg(test)]
1447#[path = "verification/tests.rs"]
1448mod tests;