#[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()
}
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(())
}
}