shepherd-core 6.6.0

The harness-agnostic shepherd engine: domain types, configuration schema, and run state. Knows nothing about any CLI, harness, or process.
//! Typed Critic and Auditor result contracts.
//!
//! Review records are facts delivered through the native result channel. They
//! are not launch configuration and they never grant write or dispatch
//! authority. The role and mode pair is closed so a Critic result cannot be
//! relabeled as an Auditor pass.

#[cfg(feature = "alloc")]
use alloc::{collections::BTreeSet, format, string::String, vec::Vec};

use super::{DispatchError, DispatchResult, LaneId, Role, RunId};

pub const REVIEW_RESULT_SCHEMA: &str = "shepherd.review-result/1";

macro_rules! closed_string_enum {
    ($name:ident { $( $variant:ident => $wire:literal ),+ $(,)? }) => {
        #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
        pub enum $name {
            $( $variant ),+
        }

        impl $name {
            #[must_use]
            pub const fn as_str(self) -> &'static str {
                match self {
                    $( Self::$variant => $wire ),+
                }
            }

            fn parse(value: &str) -> Option<Self> {
                match value {
                    $( $wire => Some(Self::$variant), )+
                    _ => None,
                }
            }
        }

        impl core::fmt::Display for $name {
            fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
                formatter.write_str(self.as_str())
            }
        }

        impl serde::Serialize for $name {
            fn serialize<S>(&self, serializer: S) -> core::result::Result<S::Ok, S::Error>
            where
                S: serde::Serializer,
            {
                serializer.serialize_str(self.as_str())
            }
        }

        impl<'de> serde::Deserialize<'de> for $name {
            fn deserialize<D>(deserializer: D) -> core::result::Result<Self, D::Error>
            where
                D: serde::Deserializer<'de>,
            {
                let value = String::deserialize(deserializer)?;
                Self::parse(&value).ok_or_else(|| {
                    serde::de::Error::unknown_variant(&value, &[$($wire),+])
                })
            }
        }
    };
}

closed_string_enum!(ReviewMode {
    CriticPrehoc => "critic-prehoc",
    AuditorPosthoc => "auditor-posthoc",
});

closed_string_enum!(ReviewVerdict {
    Green => "green",
    Pass => "pass",
    Redo => "redo",
    Red => "red",
    Blocked => "blocked",
});

closed_string_enum!(ReviewConfidence {
    StructurallyVerifiable => "structurally-verifiable",
    PlausiblePartial => "plausible-partial",
    SuggestiveOnly => "suggestive-only",
});

closed_string_enum!(ReviewSeverity {
    Critical => "critical",
    Important => "important",
    Minor => "minor",
    OpenQuestion => "open-question",
});

#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct ReviewFinding {
    pub finding_id: String,
    pub location: String,
    pub hypothesis: String,
    pub falsification_command: String,
    pub falsification_exit_status: i32,
    pub observed_result: String,
    pub confidence: ReviewConfidence,
    pub severity: ReviewSeverity,
    pub impact: String,
    pub acceptance_predicate: String,
    pub owner_role: Role,
    pub route: String,
    pub evidence_paths: Vec<String>,
}

#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct ReviewResult {
    pub schema: String,
    pub run: RunId,
    /// Only a pre-execution Critic review may be run-scoped. The native adapter
    /// must additionally bind that scope to the current planning Engineer.
    pub lane: Option<LaneId>,
    pub mode: ReviewMode,
    pub reviewer_role: Role,
    pub candidate_commit: String,
    pub input_digest: String,
    pub startup_skill: String,
    pub skill_bundle_digest: String,
    pub result_channel: String,
    pub verdict: ReviewVerdict,
    pub findings: Vec<ReviewFinding>,
    pub report_path: Option<String>,
}

impl ReviewResult {
    /// Validate a result using the closed native role capability contract.
    /// Auditor owns the optional report path; Critic always returns through
    /// the native result channel and therefore cannot carry one.
    pub fn validate(&self) -> DispatchResult<()> {
        self.validate_with_report_capability(self.reviewer_role == Role::Auditor)
    }

    /// Validate with the measured native capability set. A report path is
    /// accepted only when the adapter explicitly observed `report-write`.
    pub fn validate_with_report_capability(&self, report_write: bool) -> DispatchResult<()> {
        if self.schema != REVIEW_RESULT_SCHEMA {
            return Err(DispatchError::InvalidReview(format!(
                "unsupported schema `{}`",
                self.schema
            )));
        }
        let mode_role_valid = matches!(
            (self.mode, self.reviewer_role),
            (ReviewMode::CriticPrehoc, Role::Critic) | (ReviewMode::AuditorPosthoc, Role::Auditor)
        );
        if !mode_role_valid {
            return Err(DispatchError::InvalidReview(
                "review mode and reviewer role disagree".into(),
            ));
        }
        if self.lane.is_none() && self.mode != ReviewMode::CriticPrehoc {
            return Err(DispatchError::InvalidReview(
                "only a planning Critic review may omit its lane".into(),
            ));
        }
        if self.startup_skill != "reviewing" || self.result_channel != "native-result" {
            return Err(DispatchError::InvalidReview(
                "review result must use the reviewing skill and native-result channel".into(),
            ));
        }
        validate_hex(&self.candidate_commit, 40, "candidate_commit")?;
        validate_hex(&self.input_digest, 64, "input_digest")?;
        validate_hex(&self.skill_bundle_digest, 64, "skill_bundle_digest")?;
        match self.mode {
            ReviewMode::CriticPrehoc
                if !matches!(
                    self.verdict,
                    ReviewVerdict::Green | ReviewVerdict::Red | ReviewVerdict::Blocked
                ) =>
            {
                return Err(DispatchError::InvalidReview(
                    "Critic verdict must be green, red, or blocked".into(),
                ));
            }
            ReviewMode::AuditorPosthoc
                if !matches!(
                    self.verdict,
                    ReviewVerdict::Pass | ReviewVerdict::Redo | ReviewVerdict::Blocked
                ) =>
            {
                return Err(DispatchError::InvalidReview(
                    "Auditor verdict must be pass, redo, or blocked".into(),
                ));
            }
            _ => {}
        }
        match self.reviewer_role {
            Role::Critic => {
                if report_write || self.report_path.is_some() {
                    return Err(DispatchError::InvalidReview(
                        "Critic is native-result-only and cannot hold report-write authority"
                            .into(),
                    ));
                }
            }
            Role::Auditor => {
                let Some(path) = &self.report_path else {
                    return Err(DispatchError::InvalidReview(
                        "Auditor requires exactly one report_path".into(),
                    ));
                };
                if !report_write {
                    return Err(DispatchError::InvalidReview(
                        "Auditor report_path requires the observed report-write capability".into(),
                    ));
                }
                validate_relative_path(path, "report_path")?;
            }
            role => {
                return Err(DispatchError::InvalidReview(format!(
                    "reviewer role `{role}` is not Critic or Auditor"
                )));
            }
        }
        let mut finding_ids = BTreeSet::new();
        for finding in &self.findings {
            validate_finding(finding)?;
            if !finding_ids.insert(&finding.finding_id) {
                return Err(DispatchError::InvalidReview(format!(
                    "duplicate finding_id `{}`",
                    finding.finding_id
                )));
            }
        }
        if matches!(self.verdict, ReviewVerdict::Green | ReviewVerdict::Pass)
            && self.findings.iter().any(|finding| {
                matches!(
                    finding.severity,
                    ReviewSeverity::Critical | ReviewSeverity::Important
                )
            })
        {
            return Err(DispatchError::InvalidReview(
                "green/pass cannot carry Critical or Important findings".into(),
            ));
        }
        Ok(())
    }
}

fn validate_finding(finding: &ReviewFinding) -> DispatchResult<()> {
    for (name, value) in [
        ("finding_id", finding.finding_id.as_str()),
        ("location", finding.location.as_str()),
        ("hypothesis", finding.hypothesis.as_str()),
        (
            "falsification_command",
            finding.falsification_command.as_str(),
        ),
        ("observed_result", finding.observed_result.as_str()),
        ("impact", finding.impact.as_str()),
        (
            "acceptance_predicate",
            finding.acceptance_predicate.as_str(),
        ),
        ("route", finding.route.as_str()),
    ] {
        if value.is_empty() || value.len() > 4096 || value.chars().any(char::is_control) {
            return Err(DispatchError::InvalidReview(format!(
                "{name} is empty, oversized, or contains control text"
            )));
        }
    }
    if finding.falsification_exit_status < 0 || finding.evidence_paths.is_empty() {
        return Err(DispatchError::InvalidReview(
            "finding requires a non-negative exit status and evidence path".into(),
        ));
    }
    for path in &finding.evidence_paths {
        validate_relative_path(path, "evidence_path")?;
    }
    Ok(())
}

fn validate_relative_path(value: &str, field: &str) -> DispatchResult<()> {
    if value.is_empty()
        || value.len() > 4096
        || value.starts_with('/')
        || value.contains(['\\', '\0'])
        || value.chars().any(char::is_control)
        || value
            .split('/')
            .any(|part| part.is_empty() || part == "." || part == "..")
    {
        return Err(DispatchError::InvalidReview(format!(
            "{field} is not a contained relative path"
        )));
    }
    Ok(())
}

fn validate_hex(value: &str, length: usize, field: &str) -> DispatchResult<()> {
    if value.len() != length
        || !value
            .bytes()
            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
    {
        return Err(DispatchError::InvalidReview(format!(
            "{field} must be {length} lowercase hexadecimal characters"
        )));
    }
    Ok(())
}