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.
//! Native review-attribution and terminal fourth-rejection custody.

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

use super::{
    AgentId, DispatchError, DispatchId, DispatchResult, LaneId, ProjectId, ReviewVerdict, Role,
    RunId, SessionId,
};

pub const REVIEW_RULING_SCHEMA: &str = "shepherd.review-ruling/1";
pub const REVIEW_CUSTODY_SCHEMA: &str = "shepherd.review-custody/1";
pub const REVIEW_REJECTION_LIMIT: u8 = 3;

#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct ReviewRuling {
    pub schema: String,
    pub project_id: ProjectId,
    pub run: RunId,
    pub subject_agent_id: AgentId,
    #[serde(with = "super::pending::digest_serde")]
    pub task_sha256: [u8; 32],
    pub task_generation: u32,
    pub reviewer_dispatch_id: DispatchId,
    #[serde(with = "super::pending::digest_serde")]
    pub findings_sha256: [u8; 32],
    pub verdict: ReviewVerdict,
    pub ruled_at: i64,
}

impl ReviewRuling {
    pub fn validate(&self) -> DispatchResult<()> {
        if self.schema != REVIEW_RULING_SCHEMA
            || self.subject_agent_id.as_str() == self.reviewer_dispatch_id.as_str()
            || self.task_sha256 == [0; 32]
            || self.findings_sha256 == [0; 32]
            || self.task_generation == 0
            || self.ruled_at < 0
        {
            return Err(DispatchError::InvalidReviewCustody(
                "review ruling lacks exact subject, task generation, reviewer, findings, or time"
                    .into(),
            ));
        }
        Ok(())
    }

    #[must_use]
    pub const fn rejects_subject(&self) -> bool {
        matches!(self.verdict, ReviewVerdict::Redo | ReviewVerdict::Red)
    }
}

#[derive(
    Clone,
    Copy,
    Debug,
    Eq,
    PartialEq,
    serde::Deserialize,
    serde::Serialize,
    strum::AsRefStr,
    strum::Display,
    strum::EnumCount,
    strum::EnumIs,
    strum::EnumString,
    strum::IntoStaticStr,
    strum::VariantNames,
)]
#[serde(rename_all = "kebab-case")]
#[strum(ascii_case_insensitive, serialize_all = "snake_case")]
pub enum ReviewCustodyState {
    Active,
    Malignant,
    Replaced,
}

#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct ReviewCustody {
    pub schema: String,
    pub project_id: ProjectId,
    pub run: RunId,
    pub root_session_id: SessionId,
    pub subject_agent_id: AgentId,
    pub subject_session_id: SessionId,
    pub subject_role: Role,
    pub lane: Option<LaneId>,
    #[serde(with = "super::pending::digest_serde")]
    pub pending_launch_id_hash: [u8; 32],
    #[serde(with = "super::pending::digest_serde")]
    pub task_sha256: [u8; 32],
    pub task_generation: u32,
    pub rejected_revisions: u8,
    pub state: ReviewCustodyState,
    pub rulings: Vec<ReviewRuling>,
    pub claim_revoked: bool,
    pub write_revoked: bool,
    pub session_quarantined: bool,
    pub stopped_at: Option<i64>,
    pub updated_at: i64,
    pub destroyed_agent_id: Option<AgentId>,
    pub replacement_agent_id: Option<AgentId>,
}

impl ReviewCustody {
    pub fn begin(
        ruling: &ReviewRuling,
        root_session_id: SessionId,
        subject_session_id: SessionId,
        subject_role: Role,
        lane: Option<LaneId>,
        pending_launch_id_hash: [u8; 32],
    ) -> DispatchResult<Self> {
        ruling.validate()?;
        let value = Self {
            schema: REVIEW_CUSTODY_SCHEMA.into(),
            project_id: ruling.project_id.clone(),
            run: ruling.run.clone(),
            root_session_id,
            subject_agent_id: ruling.subject_agent_id.clone(),
            subject_session_id,
            subject_role,
            lane,
            pending_launch_id_hash,
            task_sha256: ruling.task_sha256,
            task_generation: ruling.task_generation,
            rejected_revisions: 0,
            state: ReviewCustodyState::Active,
            rulings: Vec::new(),
            claim_revoked: false,
            write_revoked: false,
            session_quarantined: false,
            stopped_at: None,
            updated_at: ruling.ruled_at,
            destroyed_agent_id: None,
            replacement_agent_id: None,
        };
        value.validate()?;
        Ok(value)
    }

    pub fn apply(&mut self, ruling: &ReviewRuling) -> DispatchResult<()> {
        self.validate()?;
        ruling.validate()?;
        if self.state != ReviewCustodyState::Active {
            return Err(DispatchError::ReviewCustodyTerminal);
        }
        if self.project_id != ruling.project_id
            || self.run != ruling.run
            || self.subject_agent_id != ruling.subject_agent_id
            || self.task_sha256 != ruling.task_sha256
            || self.task_generation != ruling.task_generation
            || ruling.ruled_at < self.updated_at
            || self.rulings.iter().any(|prior| {
                prior.reviewer_dispatch_id == ruling.reviewer_dispatch_id
                    && prior.findings_sha256 == ruling.findings_sha256
                    && prior.verdict == ruling.verdict
            })
        {
            return Err(DispatchError::InvalidReviewCustody(
                "review ruling is replayed or bound to another subject/task generation".into(),
            ));
        }
        if ruling.rejects_subject() {
            self.rejected_revisions = self.rejected_revisions.checked_add(1).ok_or_else(|| {
                DispatchError::InvalidReviewCustody("review count overflow".into())
            })?;
            if self.rejected_revisions > REVIEW_REJECTION_LIMIT {
                self.state = ReviewCustodyState::Malignant;
                self.destroyed_agent_id = Some(self.subject_agent_id.clone());
                self.claim_revoked = true;
                self.write_revoked = true;
                self.session_quarantined = true;
                self.stopped_at = Some(ruling.ruled_at);
            }
        }
        self.rulings.push(ruling.clone());
        self.updated_at = ruling.ruled_at;
        self.validate()
    }

    /// Root-authorized exceptional replacement under the same task scope.
    pub fn replace(&mut self, replacement_agent_id: AgentId, now: i64) -> DispatchResult<()> {
        self.validate()?;
        if self.state != ReviewCustodyState::Malignant
            || replacement_agent_id == self.subject_agent_id
            || now < self.updated_at
        {
            return Err(DispatchError::InvalidReviewCustody(
                "replacement requires a new identity lineaged from one malignant subject".into(),
            ));
        }
        self.state = ReviewCustodyState::Replaced;
        self.replacement_agent_id = Some(replacement_agent_id);
        self.updated_at = now;
        self.validate()
    }

    pub fn validate(&self) -> DispatchResult<()> {
        let terminal_identity = match self.state {
            ReviewCustodyState::Active => {
                self.destroyed_agent_id.is_none()
                    && self.replacement_agent_id.is_none()
                    && !self.claim_revoked
                    && !self.write_revoked
                    && !self.session_quarantined
                    && self.stopped_at.is_none()
            }
            ReviewCustodyState::Malignant => {
                self.rejected_revisions == REVIEW_REJECTION_LIMIT + 1
                    && self.destroyed_agent_id.as_ref() == Some(&self.subject_agent_id)
                    && self.replacement_agent_id.is_none()
                    && self.claim_revoked
                    && self.write_revoked
                    && self.session_quarantined
                    && self.stopped_at == Some(self.updated_at)
            }
            ReviewCustodyState::Replaced => {
                self.rejected_revisions == REVIEW_REJECTION_LIMIT + 1
                    && self.destroyed_agent_id.as_ref() == Some(&self.subject_agent_id)
                    && self
                        .replacement_agent_id
                        .as_ref()
                        .is_some_and(|replacement| replacement != &self.subject_agent_id)
                    && self.claim_revoked
                    && self.write_revoked
                    && self.session_quarantined
                    && self.stopped_at.is_some()
            }
        };
        let rejected = self
            .rulings
            .iter()
            .filter(|ruling| ruling.rejects_subject())
            .count();
        let rulings_valid = (self.rulings.is_empty()
            && self.state == ReviewCustodyState::Active
            && self.rejected_revisions == 0)
            || self.rulings.iter().all(|ruling| {
                ruling.validate().is_ok()
                    && ruling.project_id == self.project_id
                    && ruling.run == self.run
                    && ruling.subject_agent_id == self.subject_agent_id
                    && ruling.task_sha256 == self.task_sha256
                    && ruling.task_generation == self.task_generation
            });
        if self.schema != REVIEW_CUSTODY_SCHEMA
            || self.task_sha256 == [0; 32]
            || self.pending_launch_id_hash == [0; 32]
            || self.task_generation == 0
            || self.rejected_revisions > REVIEW_REJECTION_LIMIT + 1
            || usize::from(self.rejected_revisions) != rejected
            || !rulings_valid
            || self.updated_at < 0
            || !terminal_identity
        {
            return Err(DispatchError::InvalidReviewCustody(format!(
                "review custody is inconsistent for subject `{}`",
                self.subject_agent_id
            )));
        }
        Ok(())
    }
}