#[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,
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 {
pub fn validate(&self) -> DispatchResult<()> {
self.validate_with_report_capability(self.reviewer_role == Role::Auditor)
}
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(())
}