Skip to main content

proofborne_core/
agent.rs

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
9/// Version of the proof-carrying parent/child delegation protocol.
10pub const AGENT_PROTOCOL_VERSION: &str = "proofborne.agent.v1";
11
12/// Runtime role of one authority in a delegated task.
13#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
14#[serde(rename_all = "snake_case")]
15pub enum AgentRole {
16    /// Owns the user contract and may accept reviewed handoffs.
17    Coordinator,
18    /// Executes a strict subset of the parent contract.
19    Worker,
20    /// Independently inspects a worker handoff without mutation authority.
21    Reviewer,
22    /// Attempts to falsify a proposed result without mutation authority.
23    Adversary,
24}
25
26/// Secret-free, content-addressed authority assigned to one agent session.
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28#[serde(rename_all = "camelCase", deny_unknown_fields)]
29pub struct AgentAuthority {
30    /// Canonical digest over all remaining fields.
31    pub authority_hash: String,
32    /// Runtime-owned agent identity, distinct for every delegated session.
33    pub agent_id: Uuid,
34    /// Contract role for this authority.
35    pub role: AgentRole,
36    /// Explicit provider profile selected by the coordinator.
37    pub profile: String,
38    /// Provider adapter identifier.
39    pub provider: String,
40    /// Exact model selector.
41    pub model: String,
42    /// Public credential identity, never credential material.
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub credential_id: Option<String>,
45}
46
47impl AgentAuthority {
48    /// Constructs and content-addresses a secret-free authority.
49    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    /// Computes the canonical digest over the authority material.
71    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    /// Validates identity fields and the content-addressed authority hash.
84    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/// Read/write scope delegated from one immutable parent workspace generation.
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
109#[serde(rename_all = "camelCase", deny_unknown_fields)]
110pub struct WorkspaceLease {
111    /// Parent workspace generation observed before the child workspace was created.
112    pub input_workspace_generation: String,
113    /// Digest of the material copied into the isolated child workspace.
114    pub leased_input_hash: String,
115    /// Normalized workspace-relative paths visible to the child.
116    pub read_paths: BTreeSet<String>,
117    /// Normalized workspace-relative paths the child may change.
118    pub write_paths: BTreeSet<String>,
119}
120
121impl WorkspaceLease {
122    /// Validates digests and requires every write scope to be contained by a read scope.
123    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    /// Returns whether a normalized path is visible to the child.
147    pub fn permits_read(&self, path: &str) -> bool {
148        self.read_paths
149            .iter()
150            .any(|scope| scope_contains(scope, path))
151    }
152
153    /// Returns whether a normalized path may be changed by the child.
154    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/// Hard runtime budget propagated from parent to child.
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
163#[serde(rename_all = "camelCase", deny_unknown_fields)]
164pub struct AgentBudget {
165    /// Provider turns available to the worker.
166    pub max_provider_turns: usize,
167    /// Total provider-requested tool calls available to the worker.
168    pub max_tool_calls: u64,
169    /// Wall-clock ceiling for the child session.
170    pub max_duration_ms: u64,
171}
172
173impl AgentBudget {
174    /// Rejects zero-valued limits instead of silently granting an unbounded budget.
175    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/// Proof-carrying plan for exactly one parent-to-child delegation.
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
185#[serde(rename_all = "camelCase", deny_unknown_fields)]
186pub struct DelegationPlan {
187    /// Public schema identifier.
188    pub schema_version: String,
189    /// Delegation protocol version.
190    pub protocol_version: String,
191    /// Unique delegation identity.
192    pub delegation_id: Uuid,
193    /// Parent session that owns the original contract.
194    pub parent_session_id: Uuid,
195    /// Parent contract identifier.
196    pub parent_contract_id: Uuid,
197    /// Derived, confirmed child contract.
198    pub child_contract: TaskContract,
199    /// Exact parent criterion IDs delegated to the child.
200    pub delegated_criterion_ids: BTreeSet<String>,
201    /// Child execution authority.
202    pub authority: AgentAuthority,
203    /// Immutable workspace lease.
204    pub lease: WorkspaceLease,
205    /// Hard child budget.
206    pub budget: AgentBudget,
207    /// Runtime-owned action classes available in the child session.
208    pub allowed_action_classes: BTreeSet<ActionClass>,
209    /// Parent proof/state binding at delegation time.
210    pub parent_state_binding: String,
211}
212
213impl DelegationPlan {
214    /// Builds a strict child contract from a criterion subset and additive constraints.
215    #[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    /// Validates version, subset, authority, lease, budget, and authority invariants.
281    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    /// Computes a canonical content digest over the validated plan.
354    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/// Kind of one deterministic child-workspace change.
362#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
363#[serde(rename_all = "snake_case")]
364pub enum HandoffChangeKind {
365    /// A previously absent file was created.
366    Created,
367    /// An existing file changed content.
368    Modified,
369    /// An existing file was deleted.
370    Deleted,
371}
372
373/// One content-addressed path change proposed by a child.
374#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
375#[serde(rename_all = "camelCase", deny_unknown_fields)]
376pub struct HandoffChange {
377    /// Normalized workspace-relative path.
378    pub path: String,
379    /// Change classification.
380    pub kind: HandoffChangeKind,
381    /// Input digest for modifications/deletions.
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub before_digest: Option<String>,
384    /// Output digest for creations/modifications.
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub after_digest: Option<String>,
387}
388
389impl HandoffChange {
390    /// Validates path and digest shape for the selected change kind.
391    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/// Proof-carrying worker output, bound to one delegation plan and child session.
417#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
418#[serde(rename_all = "camelCase", deny_unknown_fields)]
419pub struct HandoffReceipt {
420    /// Public schema identifier.
421    pub schema_version: String,
422    /// Delegation protocol version.
423    pub protocol_version: String,
424    /// Digest of the exact delegation plan.
425    pub delegation_hash: String,
426    /// Persisted child session.
427    pub child_session_id: Uuid,
428    /// Child authority copied from the plan.
429    pub authority: AgentAuthority,
430    /// Child workspace generation after execution.
431    pub output_workspace_generation: String,
432    /// Stable path-ordered changes.
433    pub changes: Vec<HandoffChange>,
434    /// Evidence IDs produced by the child proof graph.
435    pub evidence_ids: Vec<Uuid>,
436    /// Child proof-gated outcome.
437    pub outcome: RunOutcome,
438    /// Digest of the child provider's public output text.
439    pub output_digest: String,
440}
441
442impl HandoffReceipt {
443    /// Validates all plan bindings, evidence identity, change scope, and outcome.
444    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    /// Computes the canonical receipt digest after validation.
488    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/// Independent reviewer disposition for one handoff.
500#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
501#[serde(rename_all = "snake_case")]
502pub enum ReviewDecision {
503    /// Reviewer found no blocking counterevidence.
504    Approved,
505    /// Reviewer found one or more blocking defects.
506    Rejected,
507}
508
509/// One reviewer finding with explicit criterion/path scope.
510#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
511#[serde(rename_all = "camelCase", deny_unknown_fields)]
512pub struct ReviewFinding {
513    /// Stable machine-readable finding code.
514    pub code: String,
515    /// Human-readable detail.
516    pub message: String,
517    /// Delegated criterion implicated by this finding.
518    #[serde(skip_serializing_if = "Option::is_none")]
519    pub criterion_id: Option<String>,
520    /// Workspace-relative path implicated by this finding.
521    #[serde(skip_serializing_if = "Option::is_none")]
522    pub path: Option<String>,
523}
524
525/// Review receipt produced by a separately authorized reviewer session.
526#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
527#[serde(rename_all = "camelCase", deny_unknown_fields)]
528pub struct ReviewReceipt {
529    /// Public schema identifier.
530    pub schema_version: String,
531    /// Delegation protocol version.
532    pub protocol_version: String,
533    /// Digest of the exact handoff under review.
534    pub handoff_hash: String,
535    /// Persisted reviewer session.
536    pub reviewer_session_id: Uuid,
537    /// Reviewer authority; must differ from the worker authority.
538    pub authority: AgentAuthority,
539    /// Explicit disposition.
540    pub decision: ReviewDecision,
541    /// Stable findings; approval requires an empty set.
542    pub findings: Vec<ReviewFinding>,
543    /// Workspace paths actually inspected by successful reviewer read actions.
544    pub inspected_paths: BTreeSet<String>,
545    /// Reviewer evidence IDs proving the inspection ran.
546    pub evidence_ids: Vec<Uuid>,
547}
548
549impl ReviewReceipt {
550    /// Validates independence, binding, evidence, finding scope, and disposition.
551    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    /// Computes the canonical receipt digest after validation.
625    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/// Runtime-owned receipt for an atomically accepted handoff.
638#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
639#[serde(rename_all = "camelCase", deny_unknown_fields)]
640pub struct MergeReceipt {
641    /// Public schema identifier.
642    pub schema_version: String,
643    /// Delegation protocol version.
644    pub protocol_version: String,
645    /// Bound handoff digest.
646    pub handoff_hash: String,
647    /// Bound approving review digest.
648    pub review_hash: String,
649    /// Parent workspace generation immediately before merge.
650    pub parent_generation_before: String,
651    /// Parent workspace generation immediately after merge.
652    pub parent_generation_after: String,
653    /// Exact path-ordered changes applied transactionally.
654    pub changes: Vec<HandoffChange>,
655}
656
657impl MergeReceipt {
658    /// Validates approval and immutable workspace-generation bindings.
659    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    /// Computes the canonical merge-receipt digest after validation.
689    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/// Runtime-owned reason that an unmerged delegation became unusable.
703#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
704#[serde(rename_all = "snake_case")]
705pub enum DelegationInvalidationReason {
706    /// The parent no longer matches the generation leased to the worker.
707    ParentWorkspaceGenerationChanged,
708    /// The child no longer matches the generation committed by its handoff.
709    ChildWorkspaceGenerationChanged,
710    /// Recomputed child changes differ from the reviewed handoff.
711    HandoffChangeSetDiverged,
712    /// Transactional application of the reviewed changes failed.
713    MergeApplyFailed,
714    /// A required P3B reviewer/adversary gate produced blocking counterevidence.
715    AgentGraphReviewRejected,
716    /// The graph-level wall-clock budget expired before merge.
717    AgentGraphBudgetExceeded,
718}
719
720/// Versioned receipt proving why an active delegation was invalidated.
721#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
722#[serde(rename_all = "camelCase", deny_unknown_fields)]
723pub struct DelegationInvalidation {
724    /// Public schema identifier.
725    pub schema_version: String,
726    /// Delegation protocol version.
727    pub protocol_version: String,
728    /// Digest of the exact delegation plan.
729    pub delegation_hash: String,
730    /// Stable runtime-owned invalidation reason.
731    pub reason: DelegationInvalidationReason,
732    /// Workspace generation observed when the conflict was detected.
733    #[serde(skip_serializing_if = "Option::is_none")]
734    pub observed_workspace_generation: Option<String>,
735    /// Canonical digest of recomputed changes when change-set divergence was detected.
736    #[serde(skip_serializing_if = "Option::is_none")]
737    pub observed_changes_hash: Option<String>,
738}
739
740impl DelegationInvalidation {
741    /// Constructs and validates one plan-bound invalidation receipt.
742    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    /// Validates schema, plan binding, and reason-specific observations.
762    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    /// Computes the canonical invalidation-receipt digest after validation.
807    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/// Fail-closed delegation or handoff validation error.
857#[derive(Debug, Clone, Error, PartialEq, Eq)]
858pub enum AgentError {
859    /// Public schema is not supported by this runtime.
860    #[error("unsupported agent schema: {0}")]
861    UnsupportedSchema(String),
862    /// Delegation protocol is not supported by this runtime.
863    #[error("unsupported agent protocol: {0}")]
864    UnsupportedProtocol(String),
865    /// Authority contains an empty public identity field.
866    #[error("agent authority fields must be non-empty")]
867    EmptyAuthority,
868    /// Authority hash does not match its canonical material.
869    #[error("agent authority digest is invalid")]
870    AuthorityDigest,
871    /// A workspace generation or lease hash is malformed.
872    #[error("workspace lease digest is invalid")]
873    WorkspaceDigest,
874    /// A lease omits its read scope.
875    #[error("workspace lease read paths must not be empty")]
876    EmptyLease,
877    /// A lease path is absolute, ambiguous, or traverses a parent.
878    #[error("invalid workspace lease path: {0}")]
879    InvalidLeasePath(String),
880    /// A write scope is not contained by a read scope.
881    #[error("write path is outside every read scope: {0}")]
882    WriteOutsideReadScope(String),
883    /// At least one hard budget limit is zero.
884    #[error("agent budget limits must be non-zero")]
885    InvalidBudget,
886    /// Delegation selected no acceptance criterion.
887    #[error("delegated criterion subset must not be empty")]
888    EmptyCriterionSubset,
889    /// Delegation references a criterion absent from the parent.
890    #[error("delegation references an unknown parent criterion")]
891    UnknownDelegatedCriterion,
892    /// An additive child constraint is empty.
893    #[error("delegated constraints must not be empty")]
894    EmptyConstraint,
895    /// Parent contract fails its own validation.
896    #[error("parent task contract is invalid")]
897    InvalidParentContract,
898    /// Derived child contract fails its own validation.
899    #[error("child task contract is invalid")]
900    InvalidChildContract,
901    /// Plan does not bind the confirmed parent contract.
902    #[error("delegation is not bound to the confirmed parent contract")]
903    ParentBinding,
904    /// Delegated execution authority is not a worker.
905    #[error("delegation authority must have the worker role")]
906    InvalidWorkerRole,
907    /// Parent proof-state binding is absent.
908    #[error("parent state binding must not be empty")]
909    EmptyStateBinding,
910    /// Child contract widened or altered a parent obligation.
911    #[error("child contract widens or alters delegated parent obligations")]
912    ChildContractWidened,
913    /// Child authority contains a forbidden or empty action set.
914    #[error("delegated action authority is empty or contains forbidden classes")]
915    InvalidActionAuthority,
916    /// Handoff change has inconsistent digest fields.
917    #[error("handoff change is invalid: {0}")]
918    InvalidChange(String),
919    /// Handoff names one path more than once.
920    #[error("handoff contains a duplicate path: {0}")]
921    DuplicateChange(String),
922    /// Handoff changes are not canonically path-sorted.
923    #[error("handoff changes must be strictly path-sorted")]
924    UnsortedChanges,
925    /// Handoff includes a path outside delegated write authority.
926    #[error("handoff changed a path outside the write lease: {0}")]
927    ChangeOutsideLease(String),
928    /// Handoff metadata does not match its plan.
929    #[error("handoff does not match its delegation plan")]
930    HandoffBinding,
931    /// Worker did not satisfy the child proof gate.
932    #[error("handoff child outcome is not verified")]
933    UnverifiedHandoff,
934    /// Worker or reviewer receipt omits runtime evidence.
935    #[error("handoff or review lacks runtime evidence")]
936    MissingEvidence,
937    /// Review digest does not bind the handoff.
938    #[error("review does not match the handoff")]
939    ReviewBinding,
940    /// Reviewer shares worker identity or authority.
941    #[error("reviewer is not independent from the worker")]
942    ReviewerNotIndependent,
943    /// Review decision and finding set are inconsistent.
944    #[error("review disposition and findings disagree")]
945    ReviewDisposition,
946    /// Review finding is empty or outside delegated scope.
947    #[error("review finding is empty or outside delegated scope")]
948    InvalidFinding,
949    /// Merge receipt does not bind an approved current handoff.
950    #[error("merge receipt does not match the approved handoff and lease")]
951    MergeBinding,
952    /// Canonical serialization failed.
953    #[error("agent contract serialization failed")]
954    Serialization,
955    /// Invalidation receipt does not bind the immutable delegation.
956    #[error("delegation invalidation does not match its plan")]
957    InvalidationBinding,
958    /// Invalidation reason lacks its required canonical observation.
959    #[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}