use std::{collections::BTreeSet, path::Component};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use uuid::Uuid;
use crate::{ActionClass, CriterionState, RunOutcome, SCHEMA_VERSION, TaskContract, hash_json};
pub const AGENT_PROTOCOL_VERSION: &str = "proofborne.agent.v1";
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum AgentRole {
Coordinator,
Worker,
Reviewer,
Adversary,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AgentAuthority {
pub authority_hash: String,
pub agent_id: Uuid,
pub role: AgentRole,
pub profile: String,
pub provider: String,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub credential_id: Option<String>,
}
impl AgentAuthority {
pub fn new(
role: AgentRole,
profile: impl Into<String>,
provider: impl Into<String>,
model: impl Into<String>,
credential_id: Option<String>,
) -> Result<Self, AgentError> {
let mut authority = Self {
authority_hash: String::new(),
agent_id: Uuid::now_v7(),
role,
profile: profile.into(),
provider: provider.into(),
model: model.into(),
credential_id,
};
authority.validate_material()?;
authority.authority_hash = authority.material_digest()?;
Ok(authority)
}
pub fn material_digest(&self) -> Result<String, AgentError> {
let value = serde_json::json!({
"agentId": self.agent_id,
"role": self.role,
"profile": self.profile,
"provider": self.provider,
"model": self.model,
"credentialId": self.credential_id,
});
Ok(hash_json(&value))
}
pub fn validate(&self) -> Result<(), AgentError> {
self.validate_material()?;
if !valid_digest(&self.authority_hash) || self.authority_hash != self.material_digest()? {
return Err(AgentError::AuthorityDigest);
}
Ok(())
}
fn validate_material(&self) -> Result<(), AgentError> {
if self.profile.trim().is_empty()
|| self.provider.trim().is_empty()
|| self.model.trim().is_empty()
|| self
.credential_id
.as_deref()
.is_some_and(|value| value.trim().is_empty())
{
return Err(AgentError::EmptyAuthority);
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct WorkspaceLease {
pub input_workspace_generation: String,
pub leased_input_hash: String,
pub read_paths: BTreeSet<String>,
pub write_paths: BTreeSet<String>,
}
impl WorkspaceLease {
pub fn validate(&self) -> Result<(), AgentError> {
if !valid_digest(&self.input_workspace_generation) || !valid_digest(&self.leased_input_hash)
{
return Err(AgentError::WorkspaceDigest);
}
if self.read_paths.is_empty() {
return Err(AgentError::EmptyLease);
}
for path in self.read_paths.iter().chain(&self.write_paths) {
validate_scope_path(path)?;
}
for write_path in &self.write_paths {
if !self
.read_paths
.iter()
.any(|read_path| scope_contains(read_path, write_path))
{
return Err(AgentError::WriteOutsideReadScope(write_path.clone()));
}
}
Ok(())
}
pub fn permits_read(&self, path: &str) -> bool {
self.read_paths
.iter()
.any(|scope| scope_contains(scope, path))
}
pub fn permits_write(&self, path: &str) -> bool {
self.write_paths
.iter()
.any(|scope| scope_contains(scope, path))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AgentBudget {
pub max_provider_turns: usize,
pub max_tool_calls: u64,
pub max_duration_ms: u64,
}
impl AgentBudget {
pub fn validate(&self) -> Result<(), AgentError> {
if self.max_provider_turns == 0 || self.max_tool_calls == 0 || self.max_duration_ms == 0 {
return Err(AgentError::InvalidBudget);
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct DelegationPlan {
pub schema_version: String,
pub protocol_version: String,
pub delegation_id: Uuid,
pub parent_session_id: Uuid,
pub parent_contract_id: Uuid,
pub child_contract: TaskContract,
pub delegated_criterion_ids: BTreeSet<String>,
pub authority: AgentAuthority,
pub lease: WorkspaceLease,
pub budget: AgentBudget,
pub allowed_action_classes: BTreeSet<ActionClass>,
pub parent_state_binding: String,
}
impl DelegationPlan {
#[allow(clippy::too_many_arguments)]
pub fn derive(
parent_session_id: Uuid,
parent: &TaskContract,
delegated_criterion_ids: BTreeSet<String>,
added_constraints: Vec<String>,
authority: AgentAuthority,
lease: WorkspaceLease,
budget: AgentBudget,
allowed_action_classes: BTreeSet<ActionClass>,
parent_state_binding: impl Into<String>,
) -> Result<Self, AgentError> {
if delegated_criterion_ids.is_empty() {
return Err(AgentError::EmptyCriterionSubset);
}
let mut criteria = Vec::new();
for criterion in &parent.criteria {
if delegated_criterion_ids.contains(&criterion.id) {
let mut criterion = criterion.clone();
criterion.state = CriterionState::Pending;
criterion.evidence_ids.clear();
criterion.waiver = None;
criteria.push(criterion);
}
}
if criteria.len() != delegated_criterion_ids.len() {
return Err(AgentError::UnknownDelegatedCriterion);
}
let mut constraints = parent.constraints.clone();
for constraint in added_constraints {
if constraint.trim().is_empty() {
return Err(AgentError::EmptyConstraint);
}
if !constraints.contains(&constraint) {
constraints.push(constraint);
}
}
let child_contract = TaskContract {
schema_version: parent.schema_version.clone(),
id: Uuid::now_v7(),
goal: format!("Delegated subset of {}: {}", parent.id, parent.goal),
claim_scope: parent.claim_scope,
constraints,
criteria,
created_at: parent.created_at,
confirmed: true,
};
let plan = Self {
schema_version: SCHEMA_VERSION.to_owned(),
protocol_version: AGENT_PROTOCOL_VERSION.to_owned(),
delegation_id: Uuid::now_v7(),
parent_session_id,
parent_contract_id: parent.id,
child_contract,
delegated_criterion_ids,
authority,
lease,
budget,
allowed_action_classes,
parent_state_binding: parent_state_binding.into(),
};
plan.validate(parent)?;
Ok(plan)
}
pub fn validate(&self, parent: &TaskContract) -> Result<(), AgentError> {
if self.schema_version != SCHEMA_VERSION {
return Err(AgentError::UnsupportedSchema(self.schema_version.clone()));
}
if self.protocol_version != AGENT_PROTOCOL_VERSION {
return Err(AgentError::UnsupportedProtocol(
self.protocol_version.clone(),
));
}
parent
.validate()
.map_err(|_| AgentError::InvalidParentContract)?;
self.child_contract
.validate()
.map_err(|_| AgentError::InvalidChildContract)?;
self.authority.validate()?;
self.lease.validate()?;
self.budget.validate()?;
if self.parent_contract_id != parent.id || !parent.confirmed {
return Err(AgentError::ParentBinding);
}
if self.authority.role != AgentRole::Worker {
return Err(AgentError::InvalidWorkerRole);
}
if self.parent_state_binding.trim().is_empty() {
return Err(AgentError::EmptyStateBinding);
}
if self.delegated_criterion_ids.is_empty()
|| self.child_contract.criteria.len() != self.delegated_criterion_ids.len()
{
return Err(AgentError::EmptyCriterionSubset);
}
if self.child_contract.claim_scope != parent.claim_scope || !self.child_contract.confirmed {
return Err(AgentError::ChildContractWidened);
}
if parent
.constraints
.iter()
.any(|constraint| !self.child_contract.constraints.contains(constraint))
{
return Err(AgentError::ChildContractWidened);
}
for child in &self.child_contract.criteria {
if !self.delegated_criterion_ids.contains(&child.id) {
return Err(AgentError::UnknownDelegatedCriterion);
}
let Some(parent_criterion) = parent
.criteria
.iter()
.find(|criterion| criterion.id == child.id)
else {
return Err(AgentError::UnknownDelegatedCriterion);
};
let mut expected = parent_criterion.clone();
expected.state = CriterionState::Pending;
expected.evidence_ids.clear();
expected.waiver = None;
if child != &expected {
return Err(AgentError::ChildContractWidened);
}
}
if self.allowed_action_classes.is_empty()
|| self.allowed_action_classes.contains(&ActionClass::Delegate)
|| self
.allowed_action_classes
.contains(&ActionClass::Sensitive)
{
return Err(AgentError::InvalidActionAuthority);
}
Ok(())
}
pub fn digest(&self, parent: &TaskContract) -> Result<String, AgentError> {
self.validate(parent)?;
let value = serde_json::to_value(self).map_err(|_| AgentError::Serialization)?;
Ok(hash_json(&value))
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum HandoffChangeKind {
Created,
Modified,
Deleted,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct HandoffChange {
pub path: String,
pub kind: HandoffChangeKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub before_digest: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub after_digest: Option<String>,
}
impl HandoffChange {
pub fn validate(&self) -> Result<(), AgentError> {
validate_scope_path(&self.path)?;
let valid = match self.kind {
HandoffChangeKind::Created => {
self.before_digest.is_none()
&& self.after_digest.as_deref().is_some_and(valid_digest)
}
HandoffChangeKind::Modified => {
self.before_digest.as_deref().is_some_and(valid_digest)
&& self.after_digest.as_deref().is_some_and(valid_digest)
&& self.before_digest != self.after_digest
}
HandoffChangeKind::Deleted => {
self.before_digest.as_deref().is_some_and(valid_digest)
&& self.after_digest.is_none()
}
};
if valid {
Ok(())
} else {
Err(AgentError::InvalidChange(self.path.clone()))
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct HandoffReceipt {
pub schema_version: String,
pub protocol_version: String,
pub delegation_hash: String,
pub child_session_id: Uuid,
pub authority: AgentAuthority,
pub output_workspace_generation: String,
pub changes: Vec<HandoffChange>,
pub evidence_ids: Vec<Uuid>,
pub outcome: RunOutcome,
pub output_digest: String,
}
impl HandoffReceipt {
pub fn validate(&self, plan: &DelegationPlan, parent: &TaskContract) -> Result<(), AgentError> {
plan.validate(parent)?;
if self.schema_version != SCHEMA_VERSION {
return Err(AgentError::UnsupportedSchema(self.schema_version.clone()));
}
if self.protocol_version != AGENT_PROTOCOL_VERSION {
return Err(AgentError::UnsupportedProtocol(
self.protocol_version.clone(),
));
}
if self.delegation_hash != plan.digest(parent)?
|| self.authority != plan.authority
|| !valid_digest(&self.output_workspace_generation)
|| !valid_digest(&self.output_digest)
{
return Err(AgentError::HandoffBinding);
}
if self.outcome != RunOutcome::Verified {
return Err(AgentError::UnverifiedHandoff);
}
if self.evidence_ids.is_empty() {
return Err(AgentError::MissingEvidence);
}
let mut paths = BTreeSet::new();
for change in &self.changes {
change.validate()?;
if !paths.insert(&change.path) {
return Err(AgentError::DuplicateChange(change.path.clone()));
}
if !plan.lease.permits_write(&change.path) {
return Err(AgentError::ChangeOutsideLease(change.path.clone()));
}
}
if !self
.changes
.windows(2)
.all(|pair| pair[0].path < pair[1].path)
{
return Err(AgentError::UnsortedChanges);
}
Ok(())
}
pub fn digest(
&self,
plan: &DelegationPlan,
parent: &TaskContract,
) -> Result<String, AgentError> {
self.validate(plan, parent)?;
let value = serde_json::to_value(self).map_err(|_| AgentError::Serialization)?;
Ok(hash_json(&value))
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ReviewDecision {
Approved,
Rejected,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ReviewFinding {
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub criterion_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ReviewReceipt {
pub schema_version: String,
pub protocol_version: String,
pub handoff_hash: String,
pub reviewer_session_id: Uuid,
pub authority: AgentAuthority,
pub decision: ReviewDecision,
pub findings: Vec<ReviewFinding>,
pub inspected_paths: BTreeSet<String>,
pub evidence_ids: Vec<Uuid>,
}
impl ReviewReceipt {
pub fn validate(
&self,
handoff: &HandoffReceipt,
plan: &DelegationPlan,
parent: &TaskContract,
) -> Result<(), AgentError> {
handoff.validate(plan, parent)?;
if self.schema_version != SCHEMA_VERSION {
return Err(AgentError::UnsupportedSchema(self.schema_version.clone()));
}
if self.protocol_version != AGENT_PROTOCOL_VERSION {
return Err(AgentError::UnsupportedProtocol(
self.protocol_version.clone(),
));
}
if self.handoff_hash != handoff.digest(plan, parent)? {
return Err(AgentError::ReviewBinding);
}
self.authority.validate()?;
if !matches!(
self.authority.role,
AgentRole::Reviewer | AgentRole::Adversary
) || self.authority.agent_id == handoff.authority.agent_id
|| self.authority.authority_hash == handoff.authority.authority_hash
|| self.reviewer_session_id == handoff.child_session_id
|| self.reviewer_session_id == plan.parent_session_id
{
return Err(AgentError::ReviewerNotIndependent);
}
if self.evidence_ids.is_empty() || self.inspected_paths.is_empty() {
return Err(AgentError::MissingEvidence);
}
for path in &self.inspected_paths {
validate_scope_path(path)?;
if !plan.lease.permits_read(path) {
return Err(AgentError::InvalidFinding);
}
}
if handoff
.changes
.iter()
.any(|change| !self.inspected_paths.contains(&change.path))
{
return Err(AgentError::MissingEvidence);
}
match self.decision {
ReviewDecision::Approved if !self.findings.is_empty() => {
return Err(AgentError::ReviewDisposition);
}
ReviewDecision::Rejected if self.findings.is_empty() => {
return Err(AgentError::ReviewDisposition);
}
_ => {}
}
for finding in &self.findings {
if finding.code.trim().is_empty() || finding.message.trim().is_empty() {
return Err(AgentError::InvalidFinding);
}
if let Some(criterion_id) = &finding.criterion_id
&& !plan.delegated_criterion_ids.contains(criterion_id)
{
return Err(AgentError::InvalidFinding);
}
if let Some(path) = &finding.path {
validate_scope_path(path)?;
if !plan.lease.permits_read(path) {
return Err(AgentError::InvalidFinding);
}
}
}
Ok(())
}
pub fn digest(
&self,
handoff: &HandoffReceipt,
plan: &DelegationPlan,
parent: &TaskContract,
) -> Result<String, AgentError> {
self.validate(handoff, plan, parent)?;
let value = serde_json::to_value(self).map_err(|_| AgentError::Serialization)?;
Ok(hash_json(&value))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct MergeReceipt {
pub schema_version: String,
pub protocol_version: String,
pub handoff_hash: String,
pub review_hash: String,
pub parent_generation_before: String,
pub parent_generation_after: String,
pub changes: Vec<HandoffChange>,
}
impl MergeReceipt {
pub fn validate(
&self,
handoff: &HandoffReceipt,
review: &ReviewReceipt,
plan: &DelegationPlan,
parent: &TaskContract,
) -> Result<(), AgentError> {
review.validate(handoff, plan, parent)?;
if self.schema_version != SCHEMA_VERSION {
return Err(AgentError::UnsupportedSchema(self.schema_version.clone()));
}
if self.protocol_version != AGENT_PROTOCOL_VERSION {
return Err(AgentError::UnsupportedProtocol(
self.protocol_version.clone(),
));
}
if review.authority.role != AgentRole::Reviewer
|| review.decision != ReviewDecision::Approved
|| self.handoff_hash != handoff.digest(plan, parent)?
|| self.review_hash != review.digest(handoff, plan, parent)?
|| self.parent_generation_before != plan.lease.input_workspace_generation
|| !valid_digest(&self.parent_generation_after)
|| self.changes != handoff.changes
{
return Err(AgentError::MergeBinding);
}
Ok(())
}
pub fn digest(
&self,
handoff: &HandoffReceipt,
review: &ReviewReceipt,
plan: &DelegationPlan,
parent: &TaskContract,
) -> Result<String, AgentError> {
self.validate(handoff, review, plan, parent)?;
let value = serde_json::to_value(self).map_err(|_| AgentError::Serialization)?;
Ok(hash_json(&value))
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DelegationInvalidationReason {
ParentWorkspaceGenerationChanged,
ChildWorkspaceGenerationChanged,
HandoffChangeSetDiverged,
MergeApplyFailed,
AgentGraphReviewRejected,
AgentGraphBudgetExceeded,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct DelegationInvalidation {
pub schema_version: String,
pub protocol_version: String,
pub delegation_hash: String,
pub reason: DelegationInvalidationReason,
#[serde(skip_serializing_if = "Option::is_none")]
pub observed_workspace_generation: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub observed_changes_hash: Option<String>,
}
impl DelegationInvalidation {
pub fn new(
plan: &DelegationPlan,
parent: &TaskContract,
reason: DelegationInvalidationReason,
observed_workspace_generation: Option<String>,
observed_changes_hash: Option<String>,
) -> Result<Self, AgentError> {
let invalidation = Self {
schema_version: SCHEMA_VERSION.to_owned(),
protocol_version: AGENT_PROTOCOL_VERSION.to_owned(),
delegation_hash: plan.digest(parent)?,
reason,
observed_workspace_generation,
observed_changes_hash,
};
invalidation.validate(plan, parent)?;
Ok(invalidation)
}
pub fn validate(&self, plan: &DelegationPlan, parent: &TaskContract) -> Result<(), AgentError> {
plan.validate(parent)?;
if self.schema_version != SCHEMA_VERSION {
return Err(AgentError::UnsupportedSchema(self.schema_version.clone()));
}
if self.protocol_version != AGENT_PROTOCOL_VERSION {
return Err(AgentError::UnsupportedProtocol(
self.protocol_version.clone(),
));
}
if self.delegation_hash != plan.digest(parent)? {
return Err(AgentError::InvalidationBinding);
}
if self
.observed_workspace_generation
.as_deref()
.is_some_and(|value| !valid_digest(value))
|| self
.observed_changes_hash
.as_deref()
.is_some_and(|value| !valid_digest(value))
{
return Err(AgentError::InvalidInvalidationObservation);
}
let observations_are_valid = match self.reason {
DelegationInvalidationReason::ParentWorkspaceGenerationChanged
| DelegationInvalidationReason::ChildWorkspaceGenerationChanged
| DelegationInvalidationReason::AgentGraphReviewRejected
| DelegationInvalidationReason::AgentGraphBudgetExceeded => {
self.observed_workspace_generation.is_some() && self.observed_changes_hash.is_none()
}
DelegationInvalidationReason::HandoffChangeSetDiverged => {
self.observed_workspace_generation.is_some() && self.observed_changes_hash.is_some()
}
DelegationInvalidationReason::MergeApplyFailed => {
self.observed_workspace_generation.is_some()
}
};
if !observations_are_valid {
return Err(AgentError::InvalidInvalidationObservation);
}
Ok(())
}
pub fn digest(
&self,
plan: &DelegationPlan,
parent: &TaskContract,
) -> Result<String, AgentError> {
self.validate(plan, parent)?;
let value = serde_json::to_value(self).map_err(|_| AgentError::Serialization)?;
Ok(hash_json(&value))
}
}
pub(crate) fn validate_scope_path(path: &str) -> Result<(), AgentError> {
if path.is_empty() || path == "." {
return if path == "." {
Ok(())
} else {
Err(AgentError::InvalidLeasePath(path.to_owned()))
};
}
let value = std::path::Path::new(path);
if value.is_absolute()
|| value.has_root()
|| path.contains('\\')
|| path.ends_with('/')
|| value.components().any(|component| {
!matches!(component, Component::Normal(_))
|| component.as_os_str().to_string_lossy().contains(':')
})
{
return Err(AgentError::InvalidLeasePath(path.to_owned()));
}
Ok(())
}
pub(crate) fn scope_contains(scope: &str, path: &str) -> bool {
scope == "."
|| scope == path
|| path
.strip_prefix(scope)
.is_some_and(|suffix| suffix.starts_with('/'))
}
pub(crate) fn valid_digest(value: &str) -> bool {
value.len() == 64
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
#[derive(Debug, Clone, Error, PartialEq, Eq)]
pub enum AgentError {
#[error("unsupported agent schema: {0}")]
UnsupportedSchema(String),
#[error("unsupported agent protocol: {0}")]
UnsupportedProtocol(String),
#[error("agent authority fields must be non-empty")]
EmptyAuthority,
#[error("agent authority digest is invalid")]
AuthorityDigest,
#[error("workspace lease digest is invalid")]
WorkspaceDigest,
#[error("workspace lease read paths must not be empty")]
EmptyLease,
#[error("invalid workspace lease path: {0}")]
InvalidLeasePath(String),
#[error("write path is outside every read scope: {0}")]
WriteOutsideReadScope(String),
#[error("agent budget limits must be non-zero")]
InvalidBudget,
#[error("delegated criterion subset must not be empty")]
EmptyCriterionSubset,
#[error("delegation references an unknown parent criterion")]
UnknownDelegatedCriterion,
#[error("delegated constraints must not be empty")]
EmptyConstraint,
#[error("parent task contract is invalid")]
InvalidParentContract,
#[error("child task contract is invalid")]
InvalidChildContract,
#[error("delegation is not bound to the confirmed parent contract")]
ParentBinding,
#[error("delegation authority must have the worker role")]
InvalidWorkerRole,
#[error("parent state binding must not be empty")]
EmptyStateBinding,
#[error("child contract widens or alters delegated parent obligations")]
ChildContractWidened,
#[error("delegated action authority is empty or contains forbidden classes")]
InvalidActionAuthority,
#[error("handoff change is invalid: {0}")]
InvalidChange(String),
#[error("handoff contains a duplicate path: {0}")]
DuplicateChange(String),
#[error("handoff changes must be strictly path-sorted")]
UnsortedChanges,
#[error("handoff changed a path outside the write lease: {0}")]
ChangeOutsideLease(String),
#[error("handoff does not match its delegation plan")]
HandoffBinding,
#[error("handoff child outcome is not verified")]
UnverifiedHandoff,
#[error("handoff or review lacks runtime evidence")]
MissingEvidence,
#[error("review does not match the handoff")]
ReviewBinding,
#[error("reviewer is not independent from the worker")]
ReviewerNotIndependent,
#[error("review disposition and findings disagree")]
ReviewDisposition,
#[error("review finding is empty or outside delegated scope")]
InvalidFinding,
#[error("merge receipt does not match the approved handoff and lease")]
MergeBinding,
#[error("agent contract serialization failed")]
Serialization,
#[error("delegation invalidation does not match its plan")]
InvalidationBinding,
#[error("delegation invalidation observation is invalid")]
InvalidInvalidationObservation,
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use crate::{AssuranceLevel, Criterion, EvidenceFreshness, EvidenceKind, EvidenceRequirement};
use super::*;
fn parent_contract() -> TaskContract {
let mut criterion = Criterion::required("tests", "tests pass");
criterion.evidence_requirement = EvidenceRequirement {
allowed_kinds: [EvidenceKind::Tool].into_iter().collect(),
allowed_producers: ["workspace.edit".to_owned()].into_iter().collect(),
minimum_assurance: AssuranceLevel::Observed,
freshness: EvidenceFreshness::FinalWorkspaceState,
minimum_observations: 1,
minimum_independent_producers: 1,
require_artifacts: false,
};
let mut parent = TaskContract::new("change the file", vec![criterion]);
parent.confirmed = true;
parent.constraints.push("do not weaken tests".to_owned());
parent
}
fn authority(role: AgentRole, profile: &str) -> AgentAuthority {
AgentAuthority::new(role, profile, "mock", "m1", None).unwrap()
}
fn lease() -> WorkspaceLease {
WorkspaceLease {
input_workspace_generation: "a".repeat(64),
leased_input_hash: "b".repeat(64),
read_paths: ["src".to_owned()].into_iter().collect(),
write_paths: ["src/lib.rs".to_owned()].into_iter().collect(),
}
}
fn plan(parent: &TaskContract) -> DelegationPlan {
DelegationPlan::derive(
Uuid::now_v7(),
parent,
["tests".to_owned()].into_iter().collect(),
vec!["only change leased paths".to_owned()],
authority(AgentRole::Worker, "worker"),
lease(),
AgentBudget {
max_provider_turns: 4,
max_tool_calls: 8,
max_duration_ms: 30_000,
},
[ActionClass::Read, ActionClass::WorkspaceWrite]
.into_iter()
.collect(),
"state",
)
.unwrap()
}
#[test]
fn derived_contract_is_a_strict_reset_subset() {
let parent = parent_contract();
let plan = plan(&parent);
plan.validate(&parent).unwrap();
assert_eq!(plan.child_contract.criteria.len(), 1);
assert_eq!(
plan.child_contract.criteria[0].state,
CriterionState::Pending
);
assert!(
plan.child_contract
.constraints
.contains(&"do not weaken tests".to_owned())
);
}
#[test]
fn altered_child_obligation_fails_closed() {
let parent = parent_contract();
let mut plan = plan(&parent);
plan.child_contract.criteria[0].required = false;
assert_eq!(
plan.validate(&parent),
Err(AgentError::InvalidChildContract)
);
}
#[test]
fn write_scope_must_be_visible_and_normalized() {
let mut lease = lease();
lease.write_paths = ["tests".to_owned()].into_iter().collect();
assert_eq!(
lease.validate(),
Err(AgentError::WriteOutsideReadScope("tests".to_owned()))
);
lease.write_paths = ["../src".to_owned()].into_iter().collect();
assert_eq!(
lease.validate(),
Err(AgentError::InvalidLeasePath("../src".to_owned()))
);
}
#[test]
fn read_only_workspace_lease_is_valid() {
let mut lease = lease();
lease.write_paths.clear();
lease.validate().unwrap();
lease.read_paths.clear();
assert_eq!(lease.validate(), Err(AgentError::EmptyLease));
}
#[test]
fn invalidation_receipt_is_plan_bound_and_reason_complete() {
let parent = parent_contract();
let plan = plan(&parent);
let mut invalidation = DelegationInvalidation::new(
&plan,
&parent,
DelegationInvalidationReason::HandoffChangeSetDiverged,
Some("c".repeat(64)),
Some("d".repeat(64)),
)
.unwrap();
assert_eq!(invalidation.digest(&plan, &parent).unwrap().len(), 64);
invalidation.observed_changes_hash = None;
assert_eq!(
invalidation.validate(&plan, &parent),
Err(AgentError::InvalidInvalidationObservation)
);
}
#[test]
fn reviewer_must_have_distinct_authority() {
let parent = parent_contract();
let plan = plan(&parent);
let handoff = HandoffReceipt {
schema_version: SCHEMA_VERSION.to_owned(),
protocol_version: AGENT_PROTOCOL_VERSION.to_owned(),
delegation_hash: plan.digest(&parent).unwrap(),
child_session_id: Uuid::now_v7(),
authority: plan.authority.clone(),
output_workspace_generation: "c".repeat(64),
changes: vec![HandoffChange {
path: "src/lib.rs".to_owned(),
kind: HandoffChangeKind::Modified,
before_digest: Some("d".repeat(64)),
after_digest: Some("e".repeat(64)),
}],
evidence_ids: vec![Uuid::now_v7()],
outcome: RunOutcome::Verified,
output_digest: "f".repeat(64),
};
handoff.validate(&plan, &parent).unwrap();
let review = ReviewReceipt {
schema_version: SCHEMA_VERSION.to_owned(),
protocol_version: AGENT_PROTOCOL_VERSION.to_owned(),
handoff_hash: handoff.digest(&plan, &parent).unwrap(),
reviewer_session_id: Uuid::now_v7(),
authority: plan.authority.clone(),
decision: ReviewDecision::Approved,
findings: Vec::new(),
inspected_paths: ["src/lib.rs".to_owned()].into_iter().collect(),
evidence_ids: vec![Uuid::now_v7()],
};
assert_eq!(
review.validate(&handoff, &plan, &parent),
Err(AgentError::ReviewerNotIndependent)
);
let mut parent_session_review = review;
parent_session_review.authority = authority(AgentRole::Reviewer, "reviewer");
parent_session_review.reviewer_session_id = plan.parent_session_id;
assert_eq!(
parent_session_review.validate(&handoff, &plan, &parent),
Err(AgentError::ReviewerNotIndependent)
);
}
#[test]
fn rejected_review_requires_findings() {
let parent = parent_contract();
let plan = plan(&parent);
let handoff = HandoffReceipt {
schema_version: SCHEMA_VERSION.to_owned(),
protocol_version: AGENT_PROTOCOL_VERSION.to_owned(),
delegation_hash: plan.digest(&parent).unwrap(),
child_session_id: Uuid::now_v7(),
authority: plan.authority.clone(),
output_workspace_generation: "c".repeat(64),
changes: Vec::new(),
evidence_ids: vec![Uuid::now_v7()],
outcome: RunOutcome::Verified,
output_digest: "f".repeat(64),
};
let review = ReviewReceipt {
schema_version: SCHEMA_VERSION.to_owned(),
protocol_version: AGENT_PROTOCOL_VERSION.to_owned(),
handoff_hash: handoff.digest(&plan, &parent).unwrap(),
reviewer_session_id: Uuid::now_v7(),
authority: authority(AgentRole::Reviewer, "reviewer"),
decision: ReviewDecision::Rejected,
findings: Vec::new(),
inspected_paths: ["src/lib.rs".to_owned()].into_iter().collect(),
evidence_ids: vec![Uuid::now_v7()],
};
assert_eq!(
review.validate(&handoff, &plan, &parent),
Err(AgentError::ReviewDisposition)
);
}
#[test]
fn empty_review_inspection_fails_closed() {
let parent = parent_contract();
let plan = plan(&parent);
let handoff = HandoffReceipt {
schema_version: SCHEMA_VERSION.to_owned(),
protocol_version: AGENT_PROTOCOL_VERSION.to_owned(),
delegation_hash: plan.digest(&parent).unwrap(),
child_session_id: Uuid::now_v7(),
authority: plan.authority.clone(),
output_workspace_generation: "c".repeat(64),
changes: Vec::new(),
evidence_ids: vec![Uuid::now_v7()],
outcome: RunOutcome::Verified,
output_digest: "f".repeat(64),
};
let review = ReviewReceipt {
schema_version: SCHEMA_VERSION.to_owned(),
protocol_version: AGENT_PROTOCOL_VERSION.to_owned(),
handoff_hash: handoff.digest(&plan, &parent).unwrap(),
reviewer_session_id: Uuid::now_v7(),
authority: authority(AgentRole::Reviewer, "reviewer"),
decision: ReviewDecision::Approved,
findings: Vec::new(),
inspected_paths: BTreeSet::new(),
evidence_ids: vec![Uuid::now_v7()],
};
assert_eq!(
review.validate(&handoff, &plan, &parent),
Err(AgentError::MissingEvidence)
);
}
#[test]
fn review_inspection_outside_read_lease_fails_closed() {
let parent = parent_contract();
let plan = plan(&parent);
let handoff = HandoffReceipt {
schema_version: SCHEMA_VERSION.to_owned(),
protocol_version: AGENT_PROTOCOL_VERSION.to_owned(),
delegation_hash: plan.digest(&parent).unwrap(),
child_session_id: Uuid::now_v7(),
authority: plan.authority.clone(),
output_workspace_generation: "c".repeat(64),
changes: Vec::new(),
evidence_ids: vec![Uuid::now_v7()],
outcome: RunOutcome::Verified,
output_digest: "f".repeat(64),
};
let review = ReviewReceipt {
schema_version: SCHEMA_VERSION.to_owned(),
protocol_version: AGENT_PROTOCOL_VERSION.to_owned(),
handoff_hash: handoff.digest(&plan, &parent).unwrap(),
reviewer_session_id: Uuid::now_v7(),
authority: authority(AgentRole::Reviewer, "reviewer"),
decision: ReviewDecision::Approved,
findings: Vec::new(),
inspected_paths: ["unleased.txt".to_owned()].into_iter().collect(),
evidence_ids: vec![Uuid::now_v7()],
};
assert_eq!(
review.validate(&handoff, &plan, &parent),
Err(AgentError::InvalidFinding)
);
}
#[test]
fn uninspected_handoff_change_fails_closed() {
let parent = parent_contract();
let plan = plan(&parent);
let handoff = HandoffReceipt {
schema_version: SCHEMA_VERSION.to_owned(),
protocol_version: AGENT_PROTOCOL_VERSION.to_owned(),
delegation_hash: plan.digest(&parent).unwrap(),
child_session_id: Uuid::now_v7(),
authority: plan.authority.clone(),
output_workspace_generation: "c".repeat(64),
changes: vec![HandoffChange {
path: "src/lib.rs".to_owned(),
kind: HandoffChangeKind::Modified,
before_digest: Some("d".repeat(64)),
after_digest: Some("e".repeat(64)),
}],
evidence_ids: vec![Uuid::now_v7()],
outcome: RunOutcome::Verified,
output_digest: "f".repeat(64),
};
let review = ReviewReceipt {
schema_version: SCHEMA_VERSION.to_owned(),
protocol_version: AGENT_PROTOCOL_VERSION.to_owned(),
handoff_hash: handoff.digest(&plan, &parent).unwrap(),
reviewer_session_id: Uuid::now_v7(),
authority: authority(AgentRole::Reviewer, "reviewer"),
decision: ReviewDecision::Approved,
findings: Vec::new(),
inspected_paths: ["src".to_owned()].into_iter().collect(),
evidence_ids: vec![Uuid::now_v7()],
};
assert_eq!(
review.validate(&handoff, &plan, &parent),
Err(AgentError::MissingEvidence)
);
}
#[test]
fn root_read_scope_can_contain_narrow_write_scope() {
let lease = WorkspaceLease {
read_paths: [".".to_owned()].into_iter().collect(),
..lease()
};
lease.validate().unwrap();
assert!(lease.permits_read("Cargo.toml"));
assert!(!lease.permits_write("Cargo.toml"));
}
}