1use std::{collections::BTreeSet, path::Component};
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5use uuid::Uuid;
6
7use crate::{ActionClass, CriterionState, RunOutcome, SCHEMA_VERSION, TaskContract, hash_json};
8
9pub const AGENT_PROTOCOL_VERSION: &str = "proofborne.agent.v1";
11
12#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
14#[serde(rename_all = "snake_case")]
15pub enum AgentRole {
16 Coordinator,
18 Worker,
20 Reviewer,
22 Adversary,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28#[serde(rename_all = "camelCase", deny_unknown_fields)]
29pub struct AgentAuthority {
30 pub authority_hash: String,
32 pub agent_id: Uuid,
34 pub role: AgentRole,
36 pub profile: String,
38 pub provider: String,
40 pub model: String,
42 #[serde(skip_serializing_if = "Option::is_none")]
44 pub credential_id: Option<String>,
45}
46
47impl AgentAuthority {
48 pub fn new(
50 role: AgentRole,
51 profile: impl Into<String>,
52 provider: impl Into<String>,
53 model: impl Into<String>,
54 credential_id: Option<String>,
55 ) -> Result<Self, AgentError> {
56 let mut authority = Self {
57 authority_hash: String::new(),
58 agent_id: Uuid::now_v7(),
59 role,
60 profile: profile.into(),
61 provider: provider.into(),
62 model: model.into(),
63 credential_id,
64 };
65 authority.validate_material()?;
66 authority.authority_hash = authority.material_digest()?;
67 Ok(authority)
68 }
69
70 pub fn material_digest(&self) -> Result<String, AgentError> {
72 let value = serde_json::json!({
73 "agentId": self.agent_id,
74 "role": self.role,
75 "profile": self.profile,
76 "provider": self.provider,
77 "model": self.model,
78 "credentialId": self.credential_id,
79 });
80 Ok(hash_json(&value))
81 }
82
83 pub fn validate(&self) -> Result<(), AgentError> {
85 self.validate_material()?;
86 if !valid_digest(&self.authority_hash) || self.authority_hash != self.material_digest()? {
87 return Err(AgentError::AuthorityDigest);
88 }
89 Ok(())
90 }
91
92 fn validate_material(&self) -> Result<(), AgentError> {
93 if self.profile.trim().is_empty()
94 || self.provider.trim().is_empty()
95 || self.model.trim().is_empty()
96 || self
97 .credential_id
98 .as_deref()
99 .is_some_and(|value| value.trim().is_empty())
100 {
101 return Err(AgentError::EmptyAuthority);
102 }
103 Ok(())
104 }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
109#[serde(rename_all = "camelCase", deny_unknown_fields)]
110pub struct WorkspaceLease {
111 pub input_workspace_generation: String,
113 pub leased_input_hash: String,
115 pub read_paths: BTreeSet<String>,
117 pub write_paths: BTreeSet<String>,
119}
120
121impl WorkspaceLease {
122 pub fn validate(&self) -> Result<(), AgentError> {
124 if !valid_digest(&self.input_workspace_generation) || !valid_digest(&self.leased_input_hash)
125 {
126 return Err(AgentError::WorkspaceDigest);
127 }
128 if self.read_paths.is_empty() {
129 return Err(AgentError::EmptyLease);
130 }
131 for path in self.read_paths.iter().chain(&self.write_paths) {
132 validate_scope_path(path)?;
133 }
134 for write_path in &self.write_paths {
135 if !self
136 .read_paths
137 .iter()
138 .any(|read_path| scope_contains(read_path, write_path))
139 {
140 return Err(AgentError::WriteOutsideReadScope(write_path.clone()));
141 }
142 }
143 Ok(())
144 }
145
146 pub fn permits_read(&self, path: &str) -> bool {
148 self.read_paths
149 .iter()
150 .any(|scope| scope_contains(scope, path))
151 }
152
153 pub fn permits_write(&self, path: &str) -> bool {
155 self.write_paths
156 .iter()
157 .any(|scope| scope_contains(scope, path))
158 }
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
163#[serde(rename_all = "camelCase", deny_unknown_fields)]
164pub struct AgentBudget {
165 pub max_provider_turns: usize,
167 pub max_tool_calls: u64,
169 pub max_duration_ms: u64,
171}
172
173impl AgentBudget {
174 pub fn validate(&self) -> Result<(), AgentError> {
176 if self.max_provider_turns == 0 || self.max_tool_calls == 0 || self.max_duration_ms == 0 {
177 return Err(AgentError::InvalidBudget);
178 }
179 Ok(())
180 }
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
185#[serde(rename_all = "camelCase", deny_unknown_fields)]
186pub struct DelegationPlan {
187 pub schema_version: String,
189 pub protocol_version: String,
191 pub delegation_id: Uuid,
193 pub parent_session_id: Uuid,
195 pub parent_contract_id: Uuid,
197 pub child_contract: TaskContract,
199 pub delegated_criterion_ids: BTreeSet<String>,
201 pub authority: AgentAuthority,
203 pub lease: WorkspaceLease,
205 pub budget: AgentBudget,
207 pub allowed_action_classes: BTreeSet<ActionClass>,
209 pub parent_state_binding: String,
211}
212
213impl DelegationPlan {
214 #[allow(clippy::too_many_arguments)]
216 pub fn derive(
217 parent_session_id: Uuid,
218 parent: &TaskContract,
219 delegated_criterion_ids: BTreeSet<String>,
220 added_constraints: Vec<String>,
221 authority: AgentAuthority,
222 lease: WorkspaceLease,
223 budget: AgentBudget,
224 allowed_action_classes: BTreeSet<ActionClass>,
225 parent_state_binding: impl Into<String>,
226 ) -> Result<Self, AgentError> {
227 if delegated_criterion_ids.is_empty() {
228 return Err(AgentError::EmptyCriterionSubset);
229 }
230 let mut criteria = Vec::new();
231 for criterion in &parent.criteria {
232 if delegated_criterion_ids.contains(&criterion.id) {
233 let mut criterion = criterion.clone();
234 criterion.state = CriterionState::Pending;
235 criterion.evidence_ids.clear();
236 criterion.waiver = None;
237 criteria.push(criterion);
238 }
239 }
240 if criteria.len() != delegated_criterion_ids.len() {
241 return Err(AgentError::UnknownDelegatedCriterion);
242 }
243 let mut constraints = parent.constraints.clone();
244 for constraint in added_constraints {
245 if constraint.trim().is_empty() {
246 return Err(AgentError::EmptyConstraint);
247 }
248 if !constraints.contains(&constraint) {
249 constraints.push(constraint);
250 }
251 }
252 let child_contract = TaskContract {
253 schema_version: parent.schema_version.clone(),
254 id: Uuid::now_v7(),
255 goal: format!("Delegated subset of {}: {}", parent.id, parent.goal),
256 claim_scope: parent.claim_scope,
257 constraints,
258 criteria,
259 created_at: parent.created_at,
260 confirmed: true,
261 };
262 let plan = Self {
263 schema_version: SCHEMA_VERSION.to_owned(),
264 protocol_version: AGENT_PROTOCOL_VERSION.to_owned(),
265 delegation_id: Uuid::now_v7(),
266 parent_session_id,
267 parent_contract_id: parent.id,
268 child_contract,
269 delegated_criterion_ids,
270 authority,
271 lease,
272 budget,
273 allowed_action_classes,
274 parent_state_binding: parent_state_binding.into(),
275 };
276 plan.validate(parent)?;
277 Ok(plan)
278 }
279
280 pub fn validate(&self, parent: &TaskContract) -> Result<(), AgentError> {
282 if self.schema_version != SCHEMA_VERSION {
283 return Err(AgentError::UnsupportedSchema(self.schema_version.clone()));
284 }
285 if self.protocol_version != AGENT_PROTOCOL_VERSION {
286 return Err(AgentError::UnsupportedProtocol(
287 self.protocol_version.clone(),
288 ));
289 }
290 parent
291 .validate()
292 .map_err(|_| AgentError::InvalidParentContract)?;
293 self.child_contract
294 .validate()
295 .map_err(|_| AgentError::InvalidChildContract)?;
296 self.authority.validate()?;
297 self.lease.validate()?;
298 self.budget.validate()?;
299 if self.parent_contract_id != parent.id || !parent.confirmed {
300 return Err(AgentError::ParentBinding);
301 }
302 if self.authority.role != AgentRole::Worker {
303 return Err(AgentError::InvalidWorkerRole);
304 }
305 if self.parent_state_binding.trim().is_empty() {
306 return Err(AgentError::EmptyStateBinding);
307 }
308 if self.delegated_criterion_ids.is_empty()
309 || self.child_contract.criteria.len() != self.delegated_criterion_ids.len()
310 {
311 return Err(AgentError::EmptyCriterionSubset);
312 }
313 if self.child_contract.claim_scope != parent.claim_scope || !self.child_contract.confirmed {
314 return Err(AgentError::ChildContractWidened);
315 }
316 if parent
317 .constraints
318 .iter()
319 .any(|constraint| !self.child_contract.constraints.contains(constraint))
320 {
321 return Err(AgentError::ChildContractWidened);
322 }
323 for child in &self.child_contract.criteria {
324 if !self.delegated_criterion_ids.contains(&child.id) {
325 return Err(AgentError::UnknownDelegatedCriterion);
326 }
327 let Some(parent_criterion) = parent
328 .criteria
329 .iter()
330 .find(|criterion| criterion.id == child.id)
331 else {
332 return Err(AgentError::UnknownDelegatedCriterion);
333 };
334 let mut expected = parent_criterion.clone();
335 expected.state = CriterionState::Pending;
336 expected.evidence_ids.clear();
337 expected.waiver = None;
338 if child != &expected {
339 return Err(AgentError::ChildContractWidened);
340 }
341 }
342 if self.allowed_action_classes.is_empty()
343 || self.allowed_action_classes.contains(&ActionClass::Delegate)
344 || self
345 .allowed_action_classes
346 .contains(&ActionClass::Sensitive)
347 {
348 return Err(AgentError::InvalidActionAuthority);
349 }
350 Ok(())
351 }
352
353 pub fn digest(&self, parent: &TaskContract) -> Result<String, AgentError> {
355 self.validate(parent)?;
356 let value = serde_json::to_value(self).map_err(|_| AgentError::Serialization)?;
357 Ok(hash_json(&value))
358 }
359}
360
361#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
363#[serde(rename_all = "snake_case")]
364pub enum HandoffChangeKind {
365 Created,
367 Modified,
369 Deleted,
371}
372
373#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
375#[serde(rename_all = "camelCase", deny_unknown_fields)]
376pub struct HandoffChange {
377 pub path: String,
379 pub kind: HandoffChangeKind,
381 #[serde(skip_serializing_if = "Option::is_none")]
383 pub before_digest: Option<String>,
384 #[serde(skip_serializing_if = "Option::is_none")]
386 pub after_digest: Option<String>,
387}
388
389impl HandoffChange {
390 pub fn validate(&self) -> Result<(), AgentError> {
392 validate_scope_path(&self.path)?;
393 let valid = match self.kind {
394 HandoffChangeKind::Created => {
395 self.before_digest.is_none()
396 && self.after_digest.as_deref().is_some_and(valid_digest)
397 }
398 HandoffChangeKind::Modified => {
399 self.before_digest.as_deref().is_some_and(valid_digest)
400 && self.after_digest.as_deref().is_some_and(valid_digest)
401 && self.before_digest != self.after_digest
402 }
403 HandoffChangeKind::Deleted => {
404 self.before_digest.as_deref().is_some_and(valid_digest)
405 && self.after_digest.is_none()
406 }
407 };
408 if valid {
409 Ok(())
410 } else {
411 Err(AgentError::InvalidChange(self.path.clone()))
412 }
413 }
414}
415
416#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
418#[serde(rename_all = "camelCase", deny_unknown_fields)]
419pub struct HandoffReceipt {
420 pub schema_version: String,
422 pub protocol_version: String,
424 pub delegation_hash: String,
426 pub child_session_id: Uuid,
428 pub authority: AgentAuthority,
430 pub output_workspace_generation: String,
432 pub changes: Vec<HandoffChange>,
434 pub evidence_ids: Vec<Uuid>,
436 pub outcome: RunOutcome,
438 pub output_digest: String,
440}
441
442impl HandoffReceipt {
443 pub fn validate(&self, plan: &DelegationPlan, parent: &TaskContract) -> Result<(), AgentError> {
445 plan.validate(parent)?;
446 if self.schema_version != SCHEMA_VERSION {
447 return Err(AgentError::UnsupportedSchema(self.schema_version.clone()));
448 }
449 if self.protocol_version != AGENT_PROTOCOL_VERSION {
450 return Err(AgentError::UnsupportedProtocol(
451 self.protocol_version.clone(),
452 ));
453 }
454 if self.delegation_hash != plan.digest(parent)?
455 || self.authority != plan.authority
456 || !valid_digest(&self.output_workspace_generation)
457 || !valid_digest(&self.output_digest)
458 {
459 return Err(AgentError::HandoffBinding);
460 }
461 if self.outcome != RunOutcome::Verified {
462 return Err(AgentError::UnverifiedHandoff);
463 }
464 if self.evidence_ids.is_empty() {
465 return Err(AgentError::MissingEvidence);
466 }
467 let mut paths = BTreeSet::new();
468 for change in &self.changes {
469 change.validate()?;
470 if !paths.insert(&change.path) {
471 return Err(AgentError::DuplicateChange(change.path.clone()));
472 }
473 if !plan.lease.permits_write(&change.path) {
474 return Err(AgentError::ChangeOutsideLease(change.path.clone()));
475 }
476 }
477 if !self
478 .changes
479 .windows(2)
480 .all(|pair| pair[0].path < pair[1].path)
481 {
482 return Err(AgentError::UnsortedChanges);
483 }
484 Ok(())
485 }
486
487 pub fn digest(
489 &self,
490 plan: &DelegationPlan,
491 parent: &TaskContract,
492 ) -> Result<String, AgentError> {
493 self.validate(plan, parent)?;
494 let value = serde_json::to_value(self).map_err(|_| AgentError::Serialization)?;
495 Ok(hash_json(&value))
496 }
497}
498
499#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
501#[serde(rename_all = "snake_case")]
502pub enum ReviewDecision {
503 Approved,
505 Rejected,
507}
508
509#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
511#[serde(rename_all = "camelCase", deny_unknown_fields)]
512pub struct ReviewFinding {
513 pub code: String,
515 pub message: String,
517 #[serde(skip_serializing_if = "Option::is_none")]
519 pub criterion_id: Option<String>,
520 #[serde(skip_serializing_if = "Option::is_none")]
522 pub path: Option<String>,
523}
524
525#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
527#[serde(rename_all = "camelCase", deny_unknown_fields)]
528pub struct ReviewReceipt {
529 pub schema_version: String,
531 pub protocol_version: String,
533 pub handoff_hash: String,
535 pub reviewer_session_id: Uuid,
537 pub authority: AgentAuthority,
539 pub decision: ReviewDecision,
541 pub findings: Vec<ReviewFinding>,
543 pub inspected_paths: BTreeSet<String>,
545 pub evidence_ids: Vec<Uuid>,
547}
548
549impl ReviewReceipt {
550 pub fn validate(
552 &self,
553 handoff: &HandoffReceipt,
554 plan: &DelegationPlan,
555 parent: &TaskContract,
556 ) -> Result<(), AgentError> {
557 handoff.validate(plan, parent)?;
558 if self.schema_version != SCHEMA_VERSION {
559 return Err(AgentError::UnsupportedSchema(self.schema_version.clone()));
560 }
561 if self.protocol_version != AGENT_PROTOCOL_VERSION {
562 return Err(AgentError::UnsupportedProtocol(
563 self.protocol_version.clone(),
564 ));
565 }
566 if self.handoff_hash != handoff.digest(plan, parent)? {
567 return Err(AgentError::ReviewBinding);
568 }
569 self.authority.validate()?;
570 if !matches!(
571 self.authority.role,
572 AgentRole::Reviewer | AgentRole::Adversary
573 ) || self.authority.agent_id == handoff.authority.agent_id
574 || self.authority.authority_hash == handoff.authority.authority_hash
575 || self.reviewer_session_id == handoff.child_session_id
576 || self.reviewer_session_id == plan.parent_session_id
577 {
578 return Err(AgentError::ReviewerNotIndependent);
579 }
580 if self.evidence_ids.is_empty() || self.inspected_paths.is_empty() {
581 return Err(AgentError::MissingEvidence);
582 }
583 for path in &self.inspected_paths {
584 validate_scope_path(path)?;
585 if !plan.lease.permits_read(path) {
586 return Err(AgentError::InvalidFinding);
587 }
588 }
589 if handoff
590 .changes
591 .iter()
592 .any(|change| !self.inspected_paths.contains(&change.path))
593 {
594 return Err(AgentError::MissingEvidence);
595 }
596 match self.decision {
597 ReviewDecision::Approved if !self.findings.is_empty() => {
598 return Err(AgentError::ReviewDisposition);
599 }
600 ReviewDecision::Rejected if self.findings.is_empty() => {
601 return Err(AgentError::ReviewDisposition);
602 }
603 _ => {}
604 }
605 for finding in &self.findings {
606 if finding.code.trim().is_empty() || finding.message.trim().is_empty() {
607 return Err(AgentError::InvalidFinding);
608 }
609 if let Some(criterion_id) = &finding.criterion_id
610 && !plan.delegated_criterion_ids.contains(criterion_id)
611 {
612 return Err(AgentError::InvalidFinding);
613 }
614 if let Some(path) = &finding.path {
615 validate_scope_path(path)?;
616 if !plan.lease.permits_read(path) {
617 return Err(AgentError::InvalidFinding);
618 }
619 }
620 }
621 Ok(())
622 }
623
624 pub fn digest(
626 &self,
627 handoff: &HandoffReceipt,
628 plan: &DelegationPlan,
629 parent: &TaskContract,
630 ) -> Result<String, AgentError> {
631 self.validate(handoff, plan, parent)?;
632 let value = serde_json::to_value(self).map_err(|_| AgentError::Serialization)?;
633 Ok(hash_json(&value))
634 }
635}
636
637#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
639#[serde(rename_all = "camelCase", deny_unknown_fields)]
640pub struct MergeReceipt {
641 pub schema_version: String,
643 pub protocol_version: String,
645 pub handoff_hash: String,
647 pub review_hash: String,
649 pub parent_generation_before: String,
651 pub parent_generation_after: String,
653 pub changes: Vec<HandoffChange>,
655}
656
657impl MergeReceipt {
658 pub fn validate(
660 &self,
661 handoff: &HandoffReceipt,
662 review: &ReviewReceipt,
663 plan: &DelegationPlan,
664 parent: &TaskContract,
665 ) -> Result<(), AgentError> {
666 review.validate(handoff, plan, parent)?;
667 if self.schema_version != SCHEMA_VERSION {
668 return Err(AgentError::UnsupportedSchema(self.schema_version.clone()));
669 }
670 if self.protocol_version != AGENT_PROTOCOL_VERSION {
671 return Err(AgentError::UnsupportedProtocol(
672 self.protocol_version.clone(),
673 ));
674 }
675 if review.authority.role != AgentRole::Reviewer
676 || review.decision != ReviewDecision::Approved
677 || self.handoff_hash != handoff.digest(plan, parent)?
678 || self.review_hash != review.digest(handoff, plan, parent)?
679 || self.parent_generation_before != plan.lease.input_workspace_generation
680 || !valid_digest(&self.parent_generation_after)
681 || self.changes != handoff.changes
682 {
683 return Err(AgentError::MergeBinding);
684 }
685 Ok(())
686 }
687
688 pub fn digest(
690 &self,
691 handoff: &HandoffReceipt,
692 review: &ReviewReceipt,
693 plan: &DelegationPlan,
694 parent: &TaskContract,
695 ) -> Result<String, AgentError> {
696 self.validate(handoff, review, plan, parent)?;
697 let value = serde_json::to_value(self).map_err(|_| AgentError::Serialization)?;
698 Ok(hash_json(&value))
699 }
700}
701
702#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
704#[serde(rename_all = "snake_case")]
705pub enum DelegationInvalidationReason {
706 ParentWorkspaceGenerationChanged,
708 ChildWorkspaceGenerationChanged,
710 HandoffChangeSetDiverged,
712 MergeApplyFailed,
714 AgentGraphReviewRejected,
716 AgentGraphBudgetExceeded,
718}
719
720#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
722#[serde(rename_all = "camelCase", deny_unknown_fields)]
723pub struct DelegationInvalidation {
724 pub schema_version: String,
726 pub protocol_version: String,
728 pub delegation_hash: String,
730 pub reason: DelegationInvalidationReason,
732 #[serde(skip_serializing_if = "Option::is_none")]
734 pub observed_workspace_generation: Option<String>,
735 #[serde(skip_serializing_if = "Option::is_none")]
737 pub observed_changes_hash: Option<String>,
738}
739
740impl DelegationInvalidation {
741 pub fn new(
743 plan: &DelegationPlan,
744 parent: &TaskContract,
745 reason: DelegationInvalidationReason,
746 observed_workspace_generation: Option<String>,
747 observed_changes_hash: Option<String>,
748 ) -> Result<Self, AgentError> {
749 let invalidation = Self {
750 schema_version: SCHEMA_VERSION.to_owned(),
751 protocol_version: AGENT_PROTOCOL_VERSION.to_owned(),
752 delegation_hash: plan.digest(parent)?,
753 reason,
754 observed_workspace_generation,
755 observed_changes_hash,
756 };
757 invalidation.validate(plan, parent)?;
758 Ok(invalidation)
759 }
760
761 pub fn validate(&self, plan: &DelegationPlan, parent: &TaskContract) -> Result<(), AgentError> {
763 plan.validate(parent)?;
764 if self.schema_version != SCHEMA_VERSION {
765 return Err(AgentError::UnsupportedSchema(self.schema_version.clone()));
766 }
767 if self.protocol_version != AGENT_PROTOCOL_VERSION {
768 return Err(AgentError::UnsupportedProtocol(
769 self.protocol_version.clone(),
770 ));
771 }
772 if self.delegation_hash != plan.digest(parent)? {
773 return Err(AgentError::InvalidationBinding);
774 }
775 if self
776 .observed_workspace_generation
777 .as_deref()
778 .is_some_and(|value| !valid_digest(value))
779 || self
780 .observed_changes_hash
781 .as_deref()
782 .is_some_and(|value| !valid_digest(value))
783 {
784 return Err(AgentError::InvalidInvalidationObservation);
785 }
786 let observations_are_valid = match self.reason {
787 DelegationInvalidationReason::ParentWorkspaceGenerationChanged
788 | DelegationInvalidationReason::ChildWorkspaceGenerationChanged
789 | DelegationInvalidationReason::AgentGraphReviewRejected
790 | DelegationInvalidationReason::AgentGraphBudgetExceeded => {
791 self.observed_workspace_generation.is_some() && self.observed_changes_hash.is_none()
792 }
793 DelegationInvalidationReason::HandoffChangeSetDiverged => {
794 self.observed_workspace_generation.is_some() && self.observed_changes_hash.is_some()
795 }
796 DelegationInvalidationReason::MergeApplyFailed => {
797 self.observed_workspace_generation.is_some()
798 }
799 };
800 if !observations_are_valid {
801 return Err(AgentError::InvalidInvalidationObservation);
802 }
803 Ok(())
804 }
805
806 pub fn digest(
808 &self,
809 plan: &DelegationPlan,
810 parent: &TaskContract,
811 ) -> Result<String, AgentError> {
812 self.validate(plan, parent)?;
813 let value = serde_json::to_value(self).map_err(|_| AgentError::Serialization)?;
814 Ok(hash_json(&value))
815 }
816}
817
818pub(crate) fn validate_scope_path(path: &str) -> Result<(), AgentError> {
819 if path.is_empty() || path == "." {
820 return if path == "." {
821 Ok(())
822 } else {
823 Err(AgentError::InvalidLeasePath(path.to_owned()))
824 };
825 }
826 let value = std::path::Path::new(path);
827 if value.is_absolute()
828 || value.has_root()
829 || path.contains('\\')
830 || path.ends_with('/')
831 || value.components().any(|component| {
832 !matches!(component, Component::Normal(_))
833 || component.as_os_str().to_string_lossy().contains(':')
834 })
835 {
836 return Err(AgentError::InvalidLeasePath(path.to_owned()));
837 }
838 Ok(())
839}
840
841pub(crate) fn scope_contains(scope: &str, path: &str) -> bool {
842 scope == "."
843 || scope == path
844 || path
845 .strip_prefix(scope)
846 .is_some_and(|suffix| suffix.starts_with('/'))
847}
848
849pub(crate) fn valid_digest(value: &str) -> bool {
850 value.len() == 64
851 && value
852 .bytes()
853 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
854}
855
856#[derive(Debug, Clone, Error, PartialEq, Eq)]
858pub enum AgentError {
859 #[error("unsupported agent schema: {0}")]
861 UnsupportedSchema(String),
862 #[error("unsupported agent protocol: {0}")]
864 UnsupportedProtocol(String),
865 #[error("agent authority fields must be non-empty")]
867 EmptyAuthority,
868 #[error("agent authority digest is invalid")]
870 AuthorityDigest,
871 #[error("workspace lease digest is invalid")]
873 WorkspaceDigest,
874 #[error("workspace lease read paths must not be empty")]
876 EmptyLease,
877 #[error("invalid workspace lease path: {0}")]
879 InvalidLeasePath(String),
880 #[error("write path is outside every read scope: {0}")]
882 WriteOutsideReadScope(String),
883 #[error("agent budget limits must be non-zero")]
885 InvalidBudget,
886 #[error("delegated criterion subset must not be empty")]
888 EmptyCriterionSubset,
889 #[error("delegation references an unknown parent criterion")]
891 UnknownDelegatedCriterion,
892 #[error("delegated constraints must not be empty")]
894 EmptyConstraint,
895 #[error("parent task contract is invalid")]
897 InvalidParentContract,
898 #[error("child task contract is invalid")]
900 InvalidChildContract,
901 #[error("delegation is not bound to the confirmed parent contract")]
903 ParentBinding,
904 #[error("delegation authority must have the worker role")]
906 InvalidWorkerRole,
907 #[error("parent state binding must not be empty")]
909 EmptyStateBinding,
910 #[error("child contract widens or alters delegated parent obligations")]
912 ChildContractWidened,
913 #[error("delegated action authority is empty or contains forbidden classes")]
915 InvalidActionAuthority,
916 #[error("handoff change is invalid: {0}")]
918 InvalidChange(String),
919 #[error("handoff contains a duplicate path: {0}")]
921 DuplicateChange(String),
922 #[error("handoff changes must be strictly path-sorted")]
924 UnsortedChanges,
925 #[error("handoff changed a path outside the write lease: {0}")]
927 ChangeOutsideLease(String),
928 #[error("handoff does not match its delegation plan")]
930 HandoffBinding,
931 #[error("handoff child outcome is not verified")]
933 UnverifiedHandoff,
934 #[error("handoff or review lacks runtime evidence")]
936 MissingEvidence,
937 #[error("review does not match the handoff")]
939 ReviewBinding,
940 #[error("reviewer is not independent from the worker")]
942 ReviewerNotIndependent,
943 #[error("review disposition and findings disagree")]
945 ReviewDisposition,
946 #[error("review finding is empty or outside delegated scope")]
948 InvalidFinding,
949 #[error("merge receipt does not match the approved handoff and lease")]
951 MergeBinding,
952 #[error("agent contract serialization failed")]
954 Serialization,
955 #[error("delegation invalidation does not match its plan")]
957 InvalidationBinding,
958 #[error("delegation invalidation observation is invalid")]
960 InvalidInvalidationObservation,
961}
962
963#[cfg(test)]
964mod tests {
965 use std::collections::BTreeSet;
966
967 use crate::{AssuranceLevel, Criterion, EvidenceFreshness, EvidenceKind, EvidenceRequirement};
968
969 use super::*;
970
971 fn parent_contract() -> TaskContract {
972 let mut criterion = Criterion::required("tests", "tests pass");
973 criterion.evidence_requirement = EvidenceRequirement {
974 allowed_kinds: [EvidenceKind::Tool].into_iter().collect(),
975 allowed_producers: ["workspace.edit".to_owned()].into_iter().collect(),
976 minimum_assurance: AssuranceLevel::Observed,
977 freshness: EvidenceFreshness::FinalWorkspaceState,
978 minimum_observations: 1,
979 minimum_independent_producers: 1,
980 require_artifacts: false,
981 };
982 let mut parent = TaskContract::new("change the file", vec![criterion]);
983 parent.confirmed = true;
984 parent.constraints.push("do not weaken tests".to_owned());
985 parent
986 }
987
988 fn authority(role: AgentRole, profile: &str) -> AgentAuthority {
989 AgentAuthority::new(role, profile, "mock", "m1", None).unwrap()
990 }
991
992 fn lease() -> WorkspaceLease {
993 WorkspaceLease {
994 input_workspace_generation: "a".repeat(64),
995 leased_input_hash: "b".repeat(64),
996 read_paths: ["src".to_owned()].into_iter().collect(),
997 write_paths: ["src/lib.rs".to_owned()].into_iter().collect(),
998 }
999 }
1000
1001 fn plan(parent: &TaskContract) -> DelegationPlan {
1002 DelegationPlan::derive(
1003 Uuid::now_v7(),
1004 parent,
1005 ["tests".to_owned()].into_iter().collect(),
1006 vec!["only change leased paths".to_owned()],
1007 authority(AgentRole::Worker, "worker"),
1008 lease(),
1009 AgentBudget {
1010 max_provider_turns: 4,
1011 max_tool_calls: 8,
1012 max_duration_ms: 30_000,
1013 },
1014 [ActionClass::Read, ActionClass::WorkspaceWrite]
1015 .into_iter()
1016 .collect(),
1017 "state",
1018 )
1019 .unwrap()
1020 }
1021
1022 #[test]
1023 fn derived_contract_is_a_strict_reset_subset() {
1024 let parent = parent_contract();
1025 let plan = plan(&parent);
1026 plan.validate(&parent).unwrap();
1027 assert_eq!(plan.child_contract.criteria.len(), 1);
1028 assert_eq!(
1029 plan.child_contract.criteria[0].state,
1030 CriterionState::Pending
1031 );
1032 assert!(
1033 plan.child_contract
1034 .constraints
1035 .contains(&"do not weaken tests".to_owned())
1036 );
1037 }
1038
1039 #[test]
1040 fn altered_child_obligation_fails_closed() {
1041 let parent = parent_contract();
1042 let mut plan = plan(&parent);
1043 plan.child_contract.criteria[0].required = false;
1044 assert_eq!(
1045 plan.validate(&parent),
1046 Err(AgentError::InvalidChildContract)
1047 );
1048 }
1049
1050 #[test]
1051 fn write_scope_must_be_visible_and_normalized() {
1052 let mut lease = lease();
1053 lease.write_paths = ["tests".to_owned()].into_iter().collect();
1054 assert_eq!(
1055 lease.validate(),
1056 Err(AgentError::WriteOutsideReadScope("tests".to_owned()))
1057 );
1058 lease.write_paths = ["../src".to_owned()].into_iter().collect();
1059 assert_eq!(
1060 lease.validate(),
1061 Err(AgentError::InvalidLeasePath("../src".to_owned()))
1062 );
1063 }
1064
1065 #[test]
1066 fn read_only_workspace_lease_is_valid() {
1067 let mut lease = lease();
1068 lease.write_paths.clear();
1069 lease.validate().unwrap();
1070 lease.read_paths.clear();
1071 assert_eq!(lease.validate(), Err(AgentError::EmptyLease));
1072 }
1073
1074 #[test]
1075 fn invalidation_receipt_is_plan_bound_and_reason_complete() {
1076 let parent = parent_contract();
1077 let plan = plan(&parent);
1078 let mut invalidation = DelegationInvalidation::new(
1079 &plan,
1080 &parent,
1081 DelegationInvalidationReason::HandoffChangeSetDiverged,
1082 Some("c".repeat(64)),
1083 Some("d".repeat(64)),
1084 )
1085 .unwrap();
1086 assert_eq!(invalidation.digest(&plan, &parent).unwrap().len(), 64);
1087 invalidation.observed_changes_hash = None;
1088 assert_eq!(
1089 invalidation.validate(&plan, &parent),
1090 Err(AgentError::InvalidInvalidationObservation)
1091 );
1092 }
1093
1094 #[test]
1095 fn reviewer_must_have_distinct_authority() {
1096 let parent = parent_contract();
1097 let plan = plan(&parent);
1098 let handoff = HandoffReceipt {
1099 schema_version: SCHEMA_VERSION.to_owned(),
1100 protocol_version: AGENT_PROTOCOL_VERSION.to_owned(),
1101 delegation_hash: plan.digest(&parent).unwrap(),
1102 child_session_id: Uuid::now_v7(),
1103 authority: plan.authority.clone(),
1104 output_workspace_generation: "c".repeat(64),
1105 changes: vec![HandoffChange {
1106 path: "src/lib.rs".to_owned(),
1107 kind: HandoffChangeKind::Modified,
1108 before_digest: Some("d".repeat(64)),
1109 after_digest: Some("e".repeat(64)),
1110 }],
1111 evidence_ids: vec![Uuid::now_v7()],
1112 outcome: RunOutcome::Verified,
1113 output_digest: "f".repeat(64),
1114 };
1115 handoff.validate(&plan, &parent).unwrap();
1116 let review = ReviewReceipt {
1117 schema_version: SCHEMA_VERSION.to_owned(),
1118 protocol_version: AGENT_PROTOCOL_VERSION.to_owned(),
1119 handoff_hash: handoff.digest(&plan, &parent).unwrap(),
1120 reviewer_session_id: Uuid::now_v7(),
1121 authority: plan.authority.clone(),
1122 decision: ReviewDecision::Approved,
1123 findings: Vec::new(),
1124 inspected_paths: ["src/lib.rs".to_owned()].into_iter().collect(),
1125 evidence_ids: vec![Uuid::now_v7()],
1126 };
1127 assert_eq!(
1128 review.validate(&handoff, &plan, &parent),
1129 Err(AgentError::ReviewerNotIndependent)
1130 );
1131 let mut parent_session_review = review;
1132 parent_session_review.authority = authority(AgentRole::Reviewer, "reviewer");
1133 parent_session_review.reviewer_session_id = plan.parent_session_id;
1134 assert_eq!(
1135 parent_session_review.validate(&handoff, &plan, &parent),
1136 Err(AgentError::ReviewerNotIndependent)
1137 );
1138 }
1139
1140 #[test]
1141 fn rejected_review_requires_findings() {
1142 let parent = parent_contract();
1143 let plan = plan(&parent);
1144 let handoff = HandoffReceipt {
1145 schema_version: SCHEMA_VERSION.to_owned(),
1146 protocol_version: AGENT_PROTOCOL_VERSION.to_owned(),
1147 delegation_hash: plan.digest(&parent).unwrap(),
1148 child_session_id: Uuid::now_v7(),
1149 authority: plan.authority.clone(),
1150 output_workspace_generation: "c".repeat(64),
1151 changes: Vec::new(),
1152 evidence_ids: vec![Uuid::now_v7()],
1153 outcome: RunOutcome::Verified,
1154 output_digest: "f".repeat(64),
1155 };
1156 let review = ReviewReceipt {
1157 schema_version: SCHEMA_VERSION.to_owned(),
1158 protocol_version: AGENT_PROTOCOL_VERSION.to_owned(),
1159 handoff_hash: handoff.digest(&plan, &parent).unwrap(),
1160 reviewer_session_id: Uuid::now_v7(),
1161 authority: authority(AgentRole::Reviewer, "reviewer"),
1162 decision: ReviewDecision::Rejected,
1163 findings: Vec::new(),
1164 inspected_paths: ["src/lib.rs".to_owned()].into_iter().collect(),
1165 evidence_ids: vec![Uuid::now_v7()],
1166 };
1167 assert_eq!(
1168 review.validate(&handoff, &plan, &parent),
1169 Err(AgentError::ReviewDisposition)
1170 );
1171 }
1172
1173 #[test]
1174 fn empty_review_inspection_fails_closed() {
1175 let parent = parent_contract();
1176 let plan = plan(&parent);
1177 let handoff = HandoffReceipt {
1178 schema_version: SCHEMA_VERSION.to_owned(),
1179 protocol_version: AGENT_PROTOCOL_VERSION.to_owned(),
1180 delegation_hash: plan.digest(&parent).unwrap(),
1181 child_session_id: Uuid::now_v7(),
1182 authority: plan.authority.clone(),
1183 output_workspace_generation: "c".repeat(64),
1184 changes: Vec::new(),
1185 evidence_ids: vec![Uuid::now_v7()],
1186 outcome: RunOutcome::Verified,
1187 output_digest: "f".repeat(64),
1188 };
1189 let review = ReviewReceipt {
1190 schema_version: SCHEMA_VERSION.to_owned(),
1191 protocol_version: AGENT_PROTOCOL_VERSION.to_owned(),
1192 handoff_hash: handoff.digest(&plan, &parent).unwrap(),
1193 reviewer_session_id: Uuid::now_v7(),
1194 authority: authority(AgentRole::Reviewer, "reviewer"),
1195 decision: ReviewDecision::Approved,
1196 findings: Vec::new(),
1197 inspected_paths: BTreeSet::new(),
1198 evidence_ids: vec![Uuid::now_v7()],
1199 };
1200 assert_eq!(
1201 review.validate(&handoff, &plan, &parent),
1202 Err(AgentError::MissingEvidence)
1203 );
1204 }
1205
1206 #[test]
1207 fn review_inspection_outside_read_lease_fails_closed() {
1208 let parent = parent_contract();
1209 let plan = plan(&parent);
1210 let handoff = HandoffReceipt {
1211 schema_version: SCHEMA_VERSION.to_owned(),
1212 protocol_version: AGENT_PROTOCOL_VERSION.to_owned(),
1213 delegation_hash: plan.digest(&parent).unwrap(),
1214 child_session_id: Uuid::now_v7(),
1215 authority: plan.authority.clone(),
1216 output_workspace_generation: "c".repeat(64),
1217 changes: Vec::new(),
1218 evidence_ids: vec![Uuid::now_v7()],
1219 outcome: RunOutcome::Verified,
1220 output_digest: "f".repeat(64),
1221 };
1222 let review = ReviewReceipt {
1223 schema_version: SCHEMA_VERSION.to_owned(),
1224 protocol_version: AGENT_PROTOCOL_VERSION.to_owned(),
1225 handoff_hash: handoff.digest(&plan, &parent).unwrap(),
1226 reviewer_session_id: Uuid::now_v7(),
1227 authority: authority(AgentRole::Reviewer, "reviewer"),
1228 decision: ReviewDecision::Approved,
1229 findings: Vec::new(),
1230 inspected_paths: ["unleased.txt".to_owned()].into_iter().collect(),
1231 evidence_ids: vec![Uuid::now_v7()],
1232 };
1233 assert_eq!(
1234 review.validate(&handoff, &plan, &parent),
1235 Err(AgentError::InvalidFinding)
1236 );
1237 }
1238
1239 #[test]
1240 fn uninspected_handoff_change_fails_closed() {
1241 let parent = parent_contract();
1242 let plan = plan(&parent);
1243 let handoff = HandoffReceipt {
1244 schema_version: SCHEMA_VERSION.to_owned(),
1245 protocol_version: AGENT_PROTOCOL_VERSION.to_owned(),
1246 delegation_hash: plan.digest(&parent).unwrap(),
1247 child_session_id: Uuid::now_v7(),
1248 authority: plan.authority.clone(),
1249 output_workspace_generation: "c".repeat(64),
1250 changes: vec![HandoffChange {
1251 path: "src/lib.rs".to_owned(),
1252 kind: HandoffChangeKind::Modified,
1253 before_digest: Some("d".repeat(64)),
1254 after_digest: Some("e".repeat(64)),
1255 }],
1256 evidence_ids: vec![Uuid::now_v7()],
1257 outcome: RunOutcome::Verified,
1258 output_digest: "f".repeat(64),
1259 };
1260 let review = ReviewReceipt {
1261 schema_version: SCHEMA_VERSION.to_owned(),
1262 protocol_version: AGENT_PROTOCOL_VERSION.to_owned(),
1263 handoff_hash: handoff.digest(&plan, &parent).unwrap(),
1264 reviewer_session_id: Uuid::now_v7(),
1265 authority: authority(AgentRole::Reviewer, "reviewer"),
1266 decision: ReviewDecision::Approved,
1267 findings: Vec::new(),
1268 inspected_paths: ["src".to_owned()].into_iter().collect(),
1269 evidence_ids: vec![Uuid::now_v7()],
1270 };
1271 assert_eq!(
1272 review.validate(&handoff, &plan, &parent),
1273 Err(AgentError::MissingEvidence)
1274 );
1275 }
1276
1277 #[test]
1278 fn root_read_scope_can_contain_narrow_write_scope() {
1279 let lease = WorkspaceLease {
1280 read_paths: [".".to_owned()].into_iter().collect(),
1281 ..lease()
1282 };
1283 lease.validate().unwrap();
1284 assert!(lease.permits_read("Cargo.toml"));
1285 assert!(!lease.permits_write("Cargo.toml"));
1286 }
1287}