Skip to main content

proofborne_core/
agent_graph.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5use uuid::Uuid;
6
7use crate::{
8    ActionClass, AgentAuthority, AgentBudget, AgentError, AgentRole, DelegationPlan,
9    SCHEMA_VERSION, TaskContract,
10    agent::{scope_contains, valid_digest, validate_scope_path},
11    hash_json,
12};
13
14/// Version of the proof-carrying multi-agent graph protocol.
15pub const AGENT_GRAPH_PROTOCOL_VERSION: &str = "proofborne.agent-graph.v1";
16
17/// Hard aggregate limits reserved before an agent graph may execute.
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "camelCase", deny_unknown_fields)]
20pub struct AgentGraphBudget {
21    /// Maximum number of nodes in the immutable graph.
22    pub max_nodes: usize,
23    /// Maximum number of provider-backed nodes allowed to run concurrently.
24    pub max_concurrency: usize,
25    /// Aggregate provider-turn reservations across every node.
26    pub max_provider_turns: usize,
27    /// Aggregate tool-call reservations across every node.
28    pub max_tool_calls: u64,
29    /// Wall-clock ceiling for the complete graph.
30    pub max_duration_ms: u64,
31}
32
33impl AgentGraphBudget {
34    /// Rejects zero or internally inconsistent graph limits.
35    pub fn validate(&self) -> Result<(), AgentGraphError> {
36        if self.max_nodes == 0
37            || self.max_concurrency == 0
38            || self.max_concurrency > self.max_nodes
39            || self.max_provider_turns == 0
40            || self.max_tool_calls == 0
41            || self.max_duration_ms == 0
42        {
43            return Err(AgentGraphError::InvalidGraphBudget);
44        }
45        Ok(())
46    }
47}
48
49/// Secret-free provider/model material used to mint one runtime-owned authority.
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
51#[serde(rename_all = "camelCase", deny_unknown_fields)]
52pub struct AgentAuthorityTemplate {
53    /// Provider profile selected by the coordinator.
54    pub profile: String,
55    /// Provider adapter identifier.
56    pub provider: String,
57    /// Exact model selector.
58    pub model: String,
59    /// Public credential identity, never credential material.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub credential_id: Option<String>,
62}
63
64impl AgentAuthorityTemplate {
65    fn bind(&self, role: AgentRole) -> Result<AgentAuthority, AgentGraphError> {
66        AgentAuthority::new(
67            role,
68            self.profile.clone(),
69            self.provider.clone(),
70            self.model.clone(),
71            self.credential_id.clone(),
72        )
73        .map_err(AgentGraphError::Delegation)
74    }
75}
76
77/// Parent-independent recipe for one worker, reviewer, or adversary node.
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79#[serde(rename_all = "camelCase", deny_unknown_fields)]
80pub struct AgentGraphNodeTemplate {
81    /// Stable graph-local identifier.
82    pub node_id: String,
83    /// Runtime authority class for this node.
84    pub role: AgentRole,
85    /// Nodes whose proof barriers must complete before this node is ready.
86    #[serde(default)]
87    pub dependencies: BTreeSet<String>,
88    /// Worker inspected by a reviewer or adversary node.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub review_target_node_id: Option<String>,
91    /// Exact parent criteria owned by a worker node.
92    #[serde(default)]
93    pub delegated_criterion_ids: BTreeSet<String>,
94    /// Additive constraints that may narrow, never widen, a worker contract.
95    #[serde(default)]
96    pub added_constraints: Vec<String>,
97    /// Provider/model material bound to a fresh runtime-owned authority.
98    pub authority: AgentAuthorityTemplate,
99    /// Workspace-relative scopes visible to the isolated node.
100    pub read_paths: BTreeSet<String>,
101    /// Workspace-relative scopes a worker may mutate.
102    #[serde(default)]
103    pub write_paths: BTreeSet<String>,
104    /// Hard provider/tool/time reservation for this node.
105    pub budget: AgentBudget,
106    /// Runtime-owned action classes exposed to the node.
107    pub allowed_action_classes: BTreeSet<ActionClass>,
108}
109
110impl AgentGraphNodeTemplate {
111    fn bind(&self) -> Result<AgentGraphNode, AgentGraphError> {
112        Ok(AgentGraphNode {
113            node_id: self.node_id.clone(),
114            role: self.role,
115            dependencies: self.dependencies.clone(),
116            review_target_node_id: self.review_target_node_id.clone(),
117            delegated_criterion_ids: self.delegated_criterion_ids.clone(),
118            added_constraints: self.added_constraints.clone(),
119            authority: self.authority.bind(self.role)?,
120            read_paths: self.read_paths.clone(),
121            write_paths: self.write_paths.clone(),
122            budget: self.budget.clone(),
123            allowed_action_classes: self.allowed_action_classes.clone(),
124        })
125    }
126}
127
128/// Versioned graph topology that can be bound to one exact verified parent.
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
130#[serde(rename_all = "camelCase", deny_unknown_fields)]
131pub struct AgentGraphTemplate {
132    /// Public Proofborne schema identifier.
133    pub schema_version: String,
134    /// Agent-graph protocol identifier.
135    pub protocol_version: String,
136    /// Coordinator provider/model material.
137    pub coordinator_authority: AgentAuthorityTemplate,
138    /// Aggregate reservation enforced before the first node starts.
139    pub budget: AgentGraphBudget,
140    /// Parent-independent graph recipes.
141    pub nodes: Vec<AgentGraphNodeTemplate>,
142}
143
144impl AgentGraphTemplate {
145    /// Mints runtime-owned identities and binds this topology to one parent proof state.
146    pub fn bind(
147        &self,
148        parent_session_id: Uuid,
149        parent: &TaskContract,
150        parent_state_binding: impl Into<String>,
151    ) -> Result<AgentGraphPlan, AgentGraphError> {
152        if self.schema_version != SCHEMA_VERSION {
153            return Err(AgentGraphError::UnsupportedSchema(
154                self.schema_version.clone(),
155            ));
156        }
157        if self.protocol_version != AGENT_GRAPH_PROTOCOL_VERSION {
158            return Err(AgentGraphError::UnsupportedProtocol(
159                self.protocol_version.clone(),
160            ));
161        }
162        let nodes = self
163            .nodes
164            .iter()
165            .map(AgentGraphNodeTemplate::bind)
166            .collect::<Result<Vec<_>, _>>()?;
167        AgentGraphPlan::new(
168            parent_session_id,
169            parent,
170            parent_state_binding,
171            self.coordinator_authority.bind(AgentRole::Coordinator)?,
172            self.budget.clone(),
173            nodes,
174        )
175    }
176}
177
178/// Immutable recipe for one worker, reviewer, or adversary node.
179///
180/// Workspace digests are deliberately absent: the scheduler materializes a
181/// P3A [`DelegationPlan`] against the exact parent generation only when a worker
182/// becomes dependency-ready.
183#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
184#[serde(rename_all = "camelCase", deny_unknown_fields)]
185pub struct AgentGraphNode {
186    /// Stable graph-local identifier.
187    pub node_id: String,
188    /// Runtime authority class for this node.
189    pub role: AgentRole,
190    /// Nodes whose proof barriers must complete before this node is ready.
191    #[serde(default)]
192    pub dependencies: BTreeSet<String>,
193    /// Worker inspected by a reviewer or adversary node.
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub review_target_node_id: Option<String>,
196    /// Exact parent criteria owned by a worker node.
197    #[serde(default)]
198    pub delegated_criterion_ids: BTreeSet<String>,
199    /// Additive constraints that may narrow, never widen, a worker contract.
200    #[serde(default)]
201    pub added_constraints: Vec<String>,
202    /// Secret-free provider/model identity assigned to this node.
203    pub authority: AgentAuthority,
204    /// Workspace-relative scopes visible to the isolated node.
205    pub read_paths: BTreeSet<String>,
206    /// Workspace-relative scopes a worker may mutate.
207    #[serde(default)]
208    pub write_paths: BTreeSet<String>,
209    /// Hard provider/tool/time reservation for this node.
210    pub budget: AgentBudget,
211    /// Runtime-owned action classes exposed to the node.
212    pub allowed_action_classes: BTreeSet<ActionClass>,
213}
214
215impl AgentGraphNode {
216    /// Materializes a P3A worker delegation against the current parent state.
217    pub fn derive_delegation(
218        &self,
219        parent_session_id: Uuid,
220        parent: &TaskContract,
221        input_workspace_generation: impl Into<String>,
222        leased_input_hash: impl Into<String>,
223    ) -> Result<DelegationPlan, AgentGraphError> {
224        if self.role != AgentRole::Worker {
225            return Err(AgentGraphError::NodeRole(self.node_id.clone()));
226        }
227        let input_workspace_generation = input_workspace_generation.into();
228        let lease = crate::WorkspaceLease {
229            input_workspace_generation: input_workspace_generation.clone(),
230            leased_input_hash: leased_input_hash.into(),
231            read_paths: self.read_paths.clone(),
232            write_paths: self.write_paths.clone(),
233        };
234        DelegationPlan::derive(
235            parent_session_id,
236            parent,
237            self.delegated_criterion_ids.clone(),
238            self.added_constraints.clone(),
239            self.authority.clone(),
240            lease,
241            self.budget.clone(),
242            self.allowed_action_classes.clone(),
243            input_workspace_generation,
244        )
245        .map_err(AgentGraphError::Delegation)
246    }
247
248    fn validate(&self) -> Result<(), AgentGraphError> {
249        if !valid_node_id(&self.node_id) {
250            return Err(AgentGraphError::InvalidNodeId(self.node_id.clone()));
251        }
252        self.authority
253            .validate()
254            .map_err(AgentGraphError::Delegation)?;
255        self.budget
256            .validate()
257            .map_err(AgentGraphError::Delegation)?;
258        if self.authority.role != self.role || self.role == AgentRole::Coordinator {
259            return Err(AgentGraphError::NodeRole(self.node_id.clone()));
260        }
261        if self.read_paths.is_empty() || self.allowed_action_classes.is_empty() {
262            return Err(AgentGraphError::NodeAuthority(self.node_id.clone()));
263        }
264        for path in self.read_paths.iter().chain(&self.write_paths) {
265            validate_scope_path(path).map_err(AgentGraphError::Delegation)?;
266        }
267        for write_path in &self.write_paths {
268            if !self
269                .read_paths
270                .iter()
271                .any(|read_path| scope_contains(read_path, write_path))
272            {
273                return Err(AgentGraphError::WriteOutsideReadScope {
274                    node_id: self.node_id.clone(),
275                    path: write_path.clone(),
276                });
277            }
278        }
279        match self.role {
280            AgentRole::Worker => {
281                if self.review_target_node_id.is_some()
282                    || self.delegated_criterion_ids.is_empty()
283                    || self.allowed_action_classes.contains(&ActionClass::Delegate)
284                    || self
285                        .allowed_action_classes
286                        .contains(&ActionClass::Sensitive)
287                {
288                    return Err(AgentGraphError::NodeAuthority(self.node_id.clone()));
289                }
290            }
291            AgentRole::Reviewer | AgentRole::Adversary => {
292                if self.review_target_node_id.is_none()
293                    || !self.delegated_criterion_ids.is_empty()
294                    || !self.added_constraints.is_empty()
295                    || !self.write_paths.is_empty()
296                    || !self.allowed_action_classes.contains(&ActionClass::Read)
297                    || self
298                        .allowed_action_classes
299                        .iter()
300                        .any(|action| !matches!(action, ActionClass::Read | ActionClass::Diagnose))
301                {
302                    return Err(AgentGraphError::NodeAuthority(self.node_id.clone()));
303                }
304            }
305            AgentRole::Coordinator => {
306                return Err(AgentGraphError::NodeRole(self.node_id.clone()));
307            }
308        }
309        Ok(())
310    }
311}
312
313/// Immutable, content-addressed decomposition and authority graph.
314#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
315#[serde(rename_all = "camelCase", deny_unknown_fields)]
316pub struct AgentGraphPlan {
317    /// Public Proofborne schema identifier.
318    pub schema_version: String,
319    /// Agent-graph protocol identifier.
320    pub protocol_version: String,
321    /// Unique graph identity.
322    pub graph_id: Uuid,
323    /// Coordinator session that owns the original contract.
324    pub parent_session_id: Uuid,
325    /// Original confirmed contract identity.
326    pub parent_contract_id: Uuid,
327    /// Canonical digest of the original contract.
328    pub parent_contract_hash: String,
329    /// Workspace/proof state observed before graph execution.
330    pub parent_state_binding: String,
331    /// Coordinator authority; never inherited by child nodes.
332    pub coordinator_authority: AgentAuthority,
333    /// Aggregate reservation enforced before the first node starts.
334    pub budget: AgentGraphBudget,
335    /// Canonically node-ID-sorted graph recipes.
336    pub nodes: Vec<AgentGraphNode>,
337}
338
339impl AgentGraphPlan {
340    /// Constructs, sorts, and validates a complete graph decomposition.
341    #[allow(clippy::too_many_arguments)]
342    pub fn new(
343        parent_session_id: Uuid,
344        parent: &TaskContract,
345        parent_state_binding: impl Into<String>,
346        coordinator_authority: AgentAuthority,
347        budget: AgentGraphBudget,
348        mut nodes: Vec<AgentGraphNode>,
349    ) -> Result<Self, AgentGraphError> {
350        nodes.sort_by(|left, right| left.node_id.cmp(&right.node_id));
351        let plan = Self {
352            schema_version: SCHEMA_VERSION.to_owned(),
353            protocol_version: AGENT_GRAPH_PROTOCOL_VERSION.to_owned(),
354            graph_id: Uuid::now_v7(),
355            parent_session_id,
356            parent_contract_id: parent.id,
357            parent_contract_hash: hash_json(
358                &serde_json::to_value(parent).map_err(|_| AgentGraphError::Serialization)?,
359            ),
360            parent_state_binding: parent_state_binding.into(),
361            coordinator_authority,
362            budget,
363            nodes,
364        };
365        plan.validate(parent)?;
366        Ok(plan)
367    }
368
369    /// Validates versions, decomposition, graph topology, isolation, and budgets.
370    pub fn validate(&self, parent: &TaskContract) -> Result<(), AgentGraphError> {
371        if self.schema_version != SCHEMA_VERSION {
372            return Err(AgentGraphError::UnsupportedSchema(
373                self.schema_version.clone(),
374            ));
375        }
376        if self.protocol_version != AGENT_GRAPH_PROTOCOL_VERSION {
377            return Err(AgentGraphError::UnsupportedProtocol(
378                self.protocol_version.clone(),
379            ));
380        }
381        parent
382            .validate()
383            .map_err(|_| AgentGraphError::InvalidParentContract)?;
384        if !parent.confirmed
385            || self.parent_contract_id != parent.id
386            || self.parent_contract_hash
387                != hash_json(
388                    &serde_json::to_value(parent).map_err(|_| AgentGraphError::Serialization)?,
389                )
390            || !valid_digest(&self.parent_state_binding)
391        {
392            return Err(AgentGraphError::ParentBinding);
393        }
394        self.coordinator_authority
395            .validate()
396            .map_err(AgentGraphError::Delegation)?;
397        if self.coordinator_authority.role != AgentRole::Coordinator {
398            return Err(AgentGraphError::CoordinatorRole);
399        }
400        self.budget.validate()?;
401        if self.nodes.is_empty() || self.nodes.len() > self.budget.max_nodes {
402            return Err(AgentGraphError::NodeLimit);
403        }
404        if self
405            .nodes
406            .windows(2)
407            .any(|nodes| nodes[0].node_id >= nodes[1].node_id)
408        {
409            return Err(AgentGraphError::NonCanonicalNodeOrder);
410        }
411
412        let nodes: BTreeMap<&str, &AgentGraphNode> = self
413            .nodes
414            .iter()
415            .map(|node| (node.node_id.as_str(), node))
416            .collect();
417        if nodes.len() != self.nodes.len() {
418            return Err(AgentGraphError::DuplicateNodeId);
419        }
420
421        let mut authority_ids = BTreeSet::from([self.coordinator_authority.agent_id]);
422        let mut authority_hashes =
423            BTreeSet::from([self.coordinator_authority.authority_hash.as_str()]);
424        let mut provider_turns = 0usize;
425        let mut tool_calls = 0u64;
426        for node in &self.nodes {
427            node.validate()?;
428            if !authority_ids.insert(node.authority.agent_id)
429                || !authority_hashes.insert(node.authority.authority_hash.as_str())
430            {
431                return Err(AgentGraphError::AuthorityReused(node.node_id.clone()));
432            }
433            provider_turns = provider_turns
434                .checked_add(node.budget.max_provider_turns)
435                .ok_or(AgentGraphError::GraphBudgetExceeded)?;
436            tool_calls = tool_calls
437                .checked_add(node.budget.max_tool_calls)
438                .ok_or(AgentGraphError::GraphBudgetExceeded)?;
439            if node.budget.max_duration_ms > self.budget.max_duration_ms {
440                return Err(AgentGraphError::GraphBudgetExceeded);
441            }
442            for dependency in &node.dependencies {
443                if dependency == &node.node_id || !nodes.contains_key(dependency.as_str()) {
444                    return Err(AgentGraphError::UnknownDependency {
445                        node_id: node.node_id.clone(),
446                        dependency: dependency.clone(),
447                    });
448                }
449            }
450        }
451        if provider_turns > self.budget.max_provider_turns
452            || tool_calls > self.budget.max_tool_calls
453        {
454            return Err(AgentGraphError::GraphBudgetExceeded);
455        }
456
457        self.topological_order()?;
458        self.validate_review_barriers(&nodes)?;
459        self.validate_decomposition(parent)?;
460        self.validate_sibling_write_scopes(&nodes)?;
461        Ok(())
462    }
463
464    /// Computes the canonical graph digest after full validation.
465    pub fn digest(&self, parent: &TaskContract) -> Result<String, AgentGraphError> {
466        self.validate(parent)?;
467        Ok(hash_json(
468            &serde_json::to_value(self).map_err(|_| AgentGraphError::Serialization)?,
469        ))
470    }
471
472    /// Returns the stable Kahn topological order, or fails on a cycle.
473    pub fn topological_order(&self) -> Result<Vec<String>, AgentGraphError> {
474        let mut remaining: BTreeMap<&str, BTreeSet<&str>> = self
475            .nodes
476            .iter()
477            .map(|node| {
478                (
479                    node.node_id.as_str(),
480                    node.dependencies.iter().map(String::as_str).collect(),
481                )
482            })
483            .collect();
484        let mut order = Vec::with_capacity(remaining.len());
485        while !remaining.is_empty() {
486            let ready: Vec<&str> = remaining
487                .iter()
488                .filter_map(|(node_id, dependencies)| dependencies.is_empty().then_some(*node_id))
489                .collect();
490            if ready.is_empty() {
491                return Err(AgentGraphError::Cycle);
492            }
493            for node_id in ready {
494                remaining.remove(node_id);
495                for dependencies in remaining.values_mut() {
496                    dependencies.remove(node_id);
497                }
498                order.push(node_id.to_owned());
499            }
500        }
501        Ok(order)
502    }
503
504    /// Looks up one canonical node recipe.
505    pub fn node(&self, node_id: &str) -> Option<&AgentGraphNode> {
506        self.nodes
507            .binary_search_by_key(&node_id, |node| node.node_id.as_str())
508            .ok()
509            .map(|index| &self.nodes[index])
510    }
511
512    fn validate_review_barriers(
513        &self,
514        nodes: &BTreeMap<&str, &AgentGraphNode>,
515    ) -> Result<(), AgentGraphError> {
516        let mut reviewers: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
517        let mut adversaries: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
518        for node in &self.nodes {
519            match node.role {
520                AgentRole::Worker => {
521                    if node.dependencies.iter().any(|dependency| {
522                        nodes
523                            .get(dependency.as_str())
524                            .is_some_and(|dependency| dependency.role == AgentRole::Worker)
525                    }) {
526                        return Err(AgentGraphError::IncompleteProofBarrier(
527                            node.node_id.clone(),
528                        ));
529                    }
530                }
531                AgentRole::Reviewer | AgentRole::Adversary => {
532                    let target = node
533                        .review_target_node_id
534                        .as_deref()
535                        .ok_or_else(|| AgentGraphError::ReviewTarget(node.node_id.clone()))?;
536                    if node.dependencies != BTreeSet::from([target.to_owned()])
537                        || nodes
538                            .get(target)
539                            .is_none_or(|target| target.role != AgentRole::Worker)
540                    {
541                        return Err(AgentGraphError::ReviewTarget(node.node_id.clone()));
542                    }
543                    let collection = if node.role == AgentRole::Reviewer {
544                        &mut reviewers
545                    } else {
546                        &mut adversaries
547                    };
548                    collection.entry(target).or_default().push(&node.node_id);
549                }
550                AgentRole::Coordinator => {
551                    return Err(AgentGraphError::NodeRole(node.node_id.clone()));
552                }
553            }
554        }
555        for worker in self
556            .nodes
557            .iter()
558            .filter(|node| node.role == AgentRole::Worker)
559        {
560            if reviewers.get(worker.node_id.as_str()).map(Vec::len) != Some(1)
561                || adversaries.get(worker.node_id.as_str()).map(Vec::len) != Some(1)
562            {
563                return Err(AgentGraphError::IncompleteProofBarrier(
564                    worker.node_id.clone(),
565                ));
566            }
567        }
568        for worker in self
569            .nodes
570            .iter()
571            .filter(|node| node.role == AgentRole::Worker && !node.dependencies.is_empty())
572        {
573            let mut target_workers = BTreeSet::new();
574            for dependency in &worker.dependencies {
575                let gate = nodes.get(dependency.as_str()).ok_or_else(|| {
576                    AgentGraphError::UnknownDependency {
577                        node_id: worker.node_id.clone(),
578                        dependency: dependency.clone(),
579                    }
580                })?;
581                if !matches!(gate.role, AgentRole::Reviewer | AgentRole::Adversary) {
582                    return Err(AgentGraphError::IncompleteProofBarrier(
583                        worker.node_id.clone(),
584                    ));
585                }
586                target_workers.insert(
587                    gate.review_target_node_id
588                        .as_deref()
589                        .ok_or_else(|| AgentGraphError::ReviewTarget(gate.node_id.clone()))?,
590                );
591            }
592            for target in target_workers {
593                let reviewer = reviewers
594                    .get(target)
595                    .and_then(|values| values.first())
596                    .copied()
597                    .ok_or_else(|| AgentGraphError::IncompleteProofBarrier(target.to_owned()))?;
598                let adversary = adversaries
599                    .get(target)
600                    .and_then(|values| values.first())
601                    .copied()
602                    .ok_or_else(|| AgentGraphError::IncompleteProofBarrier(target.to_owned()))?;
603                if !worker.dependencies.contains(reviewer)
604                    || !worker.dependencies.contains(adversary)
605                {
606                    return Err(AgentGraphError::IncompleteProofBarrier(
607                        worker.node_id.clone(),
608                    ));
609                }
610            }
611        }
612        Ok(())
613    }
614
615    fn validate_decomposition(&self, parent: &TaskContract) -> Result<(), AgentGraphError> {
616        let expected: BTreeSet<&str> = parent
617            .criteria
618            .iter()
619            .map(|criterion| criterion.id.as_str())
620            .collect();
621        let mut observed = BTreeSet::new();
622        for node in self
623            .nodes
624            .iter()
625            .filter(|node| node.role == AgentRole::Worker)
626        {
627            for criterion_id in &node.delegated_criterion_ids {
628                if !expected.contains(criterion_id.as_str()) {
629                    return Err(AgentGraphError::UnknownCriterion(criterion_id.clone()));
630                }
631                if !observed.insert(criterion_id.as_str()) {
632                    return Err(AgentGraphError::CriterionAssignedTwice(
633                        criterion_id.clone(),
634                    ));
635                }
636            }
637        }
638        if observed != expected {
639            return Err(AgentGraphError::IncompleteDecomposition);
640        }
641        Ok(())
642    }
643
644    fn validate_sibling_write_scopes(
645        &self,
646        nodes: &BTreeMap<&str, &AgentGraphNode>,
647    ) -> Result<(), AgentGraphError> {
648        let workers: Vec<&AgentGraphNode> = self
649            .nodes
650            .iter()
651            .filter(|node| node.role == AgentRole::Worker)
652            .collect();
653        for (index, left) in workers.iter().enumerate() {
654            for right in workers.iter().skip(index + 1) {
655                if depends_on(nodes, left.node_id.as_str(), right.node_id.as_str())
656                    || depends_on(nodes, right.node_id.as_str(), left.node_id.as_str())
657                {
658                    continue;
659                }
660                for left_scope in &left.write_paths {
661                    for right_scope in &right.write_paths {
662                        if scope_contains(left_scope, right_scope)
663                            || scope_contains(right_scope, left_scope)
664                        {
665                            return Err(AgentGraphError::SiblingWriteConflict {
666                                left_node_id: left.node_id.clone(),
667                                right_node_id: right.node_id.clone(),
668                                left_scope: left_scope.clone(),
669                                right_scope: right_scope.clone(),
670                            });
671                        }
672                    }
673                }
674            }
675        }
676        Ok(())
677    }
678}
679
680/// Durable state of one scheduler node.
681#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
682#[serde(rename_all = "snake_case")]
683pub enum AgentNodeState {
684    /// Waiting for all proof-barrier dependencies.
685    Pending,
686    /// Provider-backed execution is in progress.
687    Running,
688    /// Worker handoff is persisted and awaits both review gates.
689    HandoffReady,
690    /// Reviewer or adversary receipt is persisted and awaits fan-in.
691    AwaitingMerge,
692    /// Node and its required fan-in completed.
693    Succeeded,
694    /// A visible prerequisite or policy gate blocked the node.
695    Blocked,
696    /// Node execution or verification failed.
697    Failed,
698    /// Graph or parent cancellation reached the node.
699    Cancelled,
700    /// Previously produced work became stale or conflicting.
701    Invalidated,
702}
703
704impl AgentNodeState {
705    /// Returns whether no future scheduler transition may leave this state.
706    pub const fn is_terminal(self) -> bool {
707        matches!(
708            self,
709            Self::Succeeded | Self::Blocked | Self::Failed | Self::Cancelled | Self::Invalidated
710        )
711    }
712}
713
714/// Runtime-owned reason for one persisted state transition.
715#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
716#[serde(rename_all = "snake_case")]
717pub enum SchedulerDecisionKind {
718    /// Start a dependency-ready node.
719    Start,
720    /// Persist a worker handoff.
721    HandoffRecorded,
722    /// Persist a reviewer or adversary disposition.
723    ReviewRecorded,
724    /// Commit a reviewed handoff and release its proof barrier.
725    MergeCommitted,
726    /// Stop because a prerequisite or policy gate is unresolved.
727    Block,
728    /// Stop because execution or verification failed.
729    Fail,
730    /// Stop because graph cancellation was requested.
731    Cancel,
732    /// Invalidate stale or conflicting prior work.
733    Invalidate,
734}
735
736/// One hash-chained scheduler state transition.
737#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
738#[serde(rename_all = "camelCase", deny_unknown_fields)]
739pub struct SchedulerDecision {
740    /// Public Proofborne schema identifier.
741    pub schema_version: String,
742    /// Agent-graph protocol identifier.
743    pub protocol_version: String,
744    /// Graph receiving this transition.
745    pub graph_id: Uuid,
746    /// Zero-based append-only transition sequence.
747    pub sequence: u64,
748    /// Graph-local node receiving the transition.
749    pub node_id: String,
750    /// Runtime-owned transition reason.
751    pub kind: SchedulerDecisionKind,
752    /// Persisted state before the transition.
753    pub from_state: AgentNodeState,
754    /// Persisted state after the transition.
755    pub to_state: AgentNodeState,
756    /// Provider-backed session, when one completed.
757    #[serde(skip_serializing_if = "Option::is_none")]
758    pub session_id: Option<Uuid>,
759    /// Materialized P3A worker delegation, when applicable.
760    #[serde(skip_serializing_if = "Option::is_none")]
761    pub delegation_id: Option<Uuid>,
762    /// Canonical digest of the handoff, review, merge, or failure receipt.
763    #[serde(skip_serializing_if = "Option::is_none")]
764    pub receipt_hash: Option<String>,
765    /// Parent workspace generation observed by this transition.
766    #[serde(skip_serializing_if = "Option::is_none")]
767    pub workspace_generation: Option<String>,
768    /// Stable machine-readable terminal reason.
769    #[serde(skip_serializing_if = "Option::is_none")]
770    pub reason_code: Option<String>,
771    /// Hash of the immediately preceding scheduler decision.
772    #[serde(skip_serializing_if = "Option::is_none")]
773    pub previous_hash: Option<String>,
774    /// Canonical digest over all preceding decision fields.
775    pub hash: String,
776}
777
778impl SchedulerDecision {
779    /// Constructs and hashes one deterministic transition.
780    #[allow(clippy::too_many_arguments)]
781    pub fn new(
782        graph_id: Uuid,
783        sequence: u64,
784        node_id: impl Into<String>,
785        kind: SchedulerDecisionKind,
786        from_state: AgentNodeState,
787        to_state: AgentNodeState,
788        session_id: Option<Uuid>,
789        delegation_id: Option<Uuid>,
790        receipt_hash: Option<String>,
791        workspace_generation: Option<String>,
792        reason_code: Option<String>,
793        previous_hash: Option<String>,
794    ) -> Result<Self, AgentGraphError> {
795        let mut decision = Self {
796            schema_version: SCHEMA_VERSION.to_owned(),
797            protocol_version: AGENT_GRAPH_PROTOCOL_VERSION.to_owned(),
798            graph_id,
799            sequence,
800            node_id: node_id.into(),
801            kind,
802            from_state,
803            to_state,
804            session_id,
805            delegation_id,
806            receipt_hash,
807            workspace_generation,
808            reason_code,
809            previous_hash,
810            hash: String::new(),
811        };
812        decision.validate_material()?;
813        decision.hash = decision.material_digest();
814        Ok(decision)
815    }
816
817    /// Validates schema, transition semantics, and its canonical digest.
818    pub fn validate(&self) -> Result<(), AgentGraphError> {
819        self.validate_material()?;
820        if !valid_digest(&self.hash) || self.hash != self.material_digest() {
821            return Err(AgentGraphError::DecisionDigest);
822        }
823        Ok(())
824    }
825
826    fn validate_material(&self) -> Result<(), AgentGraphError> {
827        if self.schema_version != SCHEMA_VERSION {
828            return Err(AgentGraphError::UnsupportedSchema(
829                self.schema_version.clone(),
830            ));
831        }
832        if self.protocol_version != AGENT_GRAPH_PROTOCOL_VERSION {
833            return Err(AgentGraphError::UnsupportedProtocol(
834                self.protocol_version.clone(),
835            ));
836        }
837        if !valid_node_id(&self.node_id)
838            || self
839                .previous_hash
840                .as_deref()
841                .is_some_and(|value| !valid_digest(value))
842            || self
843                .receipt_hash
844                .as_deref()
845                .is_some_and(|value| !valid_digest(value))
846            || self
847                .workspace_generation
848                .as_deref()
849                .is_some_and(|value| !valid_digest(value))
850            || self
851                .reason_code
852                .as_deref()
853                .is_some_and(|value| value.trim().is_empty())
854        {
855            return Err(AgentGraphError::InvalidDecision);
856        }
857        let valid = match self.kind {
858            SchedulerDecisionKind::Start => {
859                self.from_state == AgentNodeState::Pending
860                    && self.to_state == AgentNodeState::Running
861                    && self.session_id.is_none()
862                    && self.receipt_hash.is_none()
863                    && self.workspace_generation.is_some()
864                    && self.reason_code.is_none()
865            }
866            SchedulerDecisionKind::HandoffRecorded => {
867                self.from_state == AgentNodeState::Running
868                    && self.to_state == AgentNodeState::HandoffReady
869                    && self.session_id.is_some()
870                    && self.delegation_id.is_some()
871                    && self.receipt_hash.is_some()
872                    && self.workspace_generation.is_some()
873                    && self.reason_code.is_none()
874            }
875            SchedulerDecisionKind::ReviewRecorded => {
876                self.from_state == AgentNodeState::Running
877                    && self.to_state == AgentNodeState::AwaitingMerge
878                    && self.session_id.is_some()
879                    && self.delegation_id.is_some()
880                    && self.receipt_hash.is_some()
881                    && self.workspace_generation.is_some()
882                    && self.reason_code.is_none()
883            }
884            SchedulerDecisionKind::MergeCommitted => {
885                matches!(
886                    self.from_state,
887                    AgentNodeState::HandoffReady | AgentNodeState::AwaitingMerge
888                ) && self.to_state == AgentNodeState::Succeeded
889                    && self.session_id.is_none()
890                    && self.delegation_id.is_some()
891                    && self.receipt_hash.is_some()
892                    && self.workspace_generation.is_some()
893                    && self
894                        .reason_code
895                        .as_deref()
896                        .is_none_or(|reason| reason == "recovered_persisted_merge")
897            }
898            SchedulerDecisionKind::Block => {
899                !self.from_state.is_terminal()
900                    && self.to_state == AgentNodeState::Blocked
901                    && self.reason_code.is_some()
902            }
903            SchedulerDecisionKind::Fail => {
904                !self.from_state.is_terminal()
905                    && self.to_state == AgentNodeState::Failed
906                    && self.reason_code.is_some()
907            }
908            SchedulerDecisionKind::Cancel => {
909                !self.from_state.is_terminal()
910                    && self.to_state == AgentNodeState::Cancelled
911                    && self.reason_code.is_some()
912            }
913            SchedulerDecisionKind::Invalidate => {
914                !self.from_state.is_terminal()
915                    && self.to_state == AgentNodeState::Invalidated
916                    && self.reason_code.is_some()
917            }
918        };
919        if !valid {
920            return Err(AgentGraphError::InvalidTransition);
921        }
922        Ok(())
923    }
924
925    fn material_digest(&self) -> String {
926        hash_json(&serde_json::json!({
927            "schemaVersion": self.schema_version,
928            "protocolVersion": self.protocol_version,
929            "graphId": self.graph_id,
930            "sequence": self.sequence,
931            "nodeId": self.node_id,
932            "kind": self.kind,
933            "fromState": self.from_state,
934            "toState": self.to_state,
935            "sessionId": self.session_id,
936            "delegationId": self.delegation_id,
937            "receiptHash": self.receipt_hash,
938            "workspaceGeneration": self.workspace_generation,
939            "reasonCode": self.reason_code,
940            "previousHash": self.previous_hash,
941        }))
942    }
943}
944
945/// Terminal outcome of one complete agent graph.
946#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
947#[serde(rename_all = "snake_case")]
948pub enum AgentGraphOutcome {
949    /// Every node and proof barrier completed.
950    Verified,
951    /// At least one visible prerequisite or policy gate blocked.
952    Blocked,
953    /// At least one node failed or was invalidated.
954    Failed,
955    /// Cancellation propagated into the graph.
956    Cancelled,
957}
958
959/// Offline-verifiable terminal scheduler receipt.
960#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
961#[serde(rename_all = "camelCase", deny_unknown_fields)]
962pub struct SchedulerReceipt {
963    /// Public Proofborne schema identifier.
964    pub schema_version: String,
965    /// Agent-graph protocol identifier.
966    pub protocol_version: String,
967    /// Canonical digest of the immutable graph plan.
968    pub graph_hash: String,
969    /// Complete hash-chained decision history.
970    pub decisions: Vec<SchedulerDecision>,
971    /// Final state for every graph node.
972    pub terminal_node_states: BTreeMap<String, AgentNodeState>,
973    /// Recomputed graph outcome.
974    pub outcome: AgentGraphOutcome,
975    /// Parent workspace generation after terminal fan-in.
976    pub final_workspace_generation: String,
977}
978
979impl SchedulerReceipt {
980    /// Replays the decision chain and verifies the claimed terminal outcome.
981    pub fn validate(
982        &self,
983        graph: &AgentGraphPlan,
984        parent: &TaskContract,
985    ) -> Result<(), AgentGraphError> {
986        graph.validate(parent)?;
987        if self.schema_version != SCHEMA_VERSION {
988            return Err(AgentGraphError::UnsupportedSchema(
989                self.schema_version.clone(),
990            ));
991        }
992        if self.protocol_version != AGENT_GRAPH_PROTOCOL_VERSION {
993            return Err(AgentGraphError::UnsupportedProtocol(
994                self.protocol_version.clone(),
995            ));
996        }
997        if self.graph_hash != graph.digest(parent)?
998            || !valid_digest(&self.final_workspace_generation)
999        {
1000            return Err(AgentGraphError::ReceiptBinding);
1001        }
1002        let mut states: BTreeMap<String, AgentNodeState> = graph
1003            .nodes
1004            .iter()
1005            .map(|node| (node.node_id.clone(), AgentNodeState::Pending))
1006            .collect();
1007        let mut previous_hash = None;
1008        for (sequence, decision) in self.decisions.iter().enumerate() {
1009            decision.validate()?;
1010            if decision.graph_id != graph.graph_id
1011                || decision.sequence != sequence as u64
1012                || decision.previous_hash != previous_hash
1013            {
1014                return Err(AgentGraphError::DecisionChain);
1015            }
1016            let node = graph
1017                .node(&decision.node_id)
1018                .ok_or_else(|| AgentGraphError::InvalidNodeId(decision.node_id.clone()))?;
1019            let kind_matches_role = match decision.kind {
1020                SchedulerDecisionKind::HandoffRecorded => node.role == AgentRole::Worker,
1021                SchedulerDecisionKind::ReviewRecorded => {
1022                    matches!(node.role, AgentRole::Reviewer | AgentRole::Adversary)
1023                }
1024                _ => true,
1025            };
1026            if !kind_matches_role {
1027                return Err(AgentGraphError::InvalidTransition);
1028            }
1029            if decision.kind == SchedulerDecisionKind::Start {
1030                let ready = match node.role {
1031                    AgentRole::Worker => node.dependencies.iter().all(|dependency| {
1032                        states.get(dependency) == Some(&AgentNodeState::Succeeded)
1033                    }),
1034                    AgentRole::Reviewer | AgentRole::Adversary => {
1035                        node.review_target_node_id.as_ref().is_some_and(|target| {
1036                            states.get(target) == Some(&AgentNodeState::HandoffReady)
1037                        })
1038                    }
1039                    AgentRole::Coordinator => false,
1040                };
1041                if !ready {
1042                    return Err(AgentGraphError::DecisionChain);
1043                }
1044            }
1045            let state = states
1046                .get_mut(&decision.node_id)
1047                .ok_or_else(|| AgentGraphError::InvalidNodeId(decision.node_id.clone()))?;
1048            if *state != decision.from_state {
1049                return Err(AgentGraphError::DecisionChain);
1050            }
1051            *state = decision.to_state;
1052            previous_hash = Some(decision.hash.clone());
1053        }
1054        let expected_final_generation = self
1055            .decisions
1056            .iter()
1057            .rev()
1058            .find(|decision| decision.kind == SchedulerDecisionKind::MergeCommitted)
1059            .and_then(|decision| decision.workspace_generation.as_deref())
1060            .unwrap_or(graph.parent_state_binding.as_str());
1061        if self.final_workspace_generation != expected_final_generation {
1062            return Err(AgentGraphError::ReceiptBinding);
1063        }
1064        if states != self.terminal_node_states || states.values().any(|state| !state.is_terminal())
1065        {
1066            return Err(AgentGraphError::TerminalStates);
1067        }
1068        let expected = if states
1069            .values()
1070            .all(|state| *state == AgentNodeState::Succeeded)
1071        {
1072            AgentGraphOutcome::Verified
1073        } else if states
1074            .values()
1075            .any(|state| *state == AgentNodeState::Cancelled)
1076        {
1077            AgentGraphOutcome::Cancelled
1078        } else if states
1079            .values()
1080            .any(|state| matches!(state, AgentNodeState::Failed | AgentNodeState::Invalidated))
1081        {
1082            AgentGraphOutcome::Failed
1083        } else {
1084            AgentGraphOutcome::Blocked
1085        };
1086        if self.outcome != expected {
1087            return Err(AgentGraphError::TerminalStates);
1088        }
1089        Ok(())
1090    }
1091
1092    /// Computes the canonical terminal receipt digest.
1093    pub fn digest(
1094        &self,
1095        graph: &AgentGraphPlan,
1096        parent: &TaskContract,
1097    ) -> Result<String, AgentGraphError> {
1098        self.validate(graph, parent)?;
1099        Ok(hash_json(
1100            &serde_json::to_value(self).map_err(|_| AgentGraphError::Serialization)?,
1101        ))
1102    }
1103}
1104
1105fn depends_on(
1106    nodes: &BTreeMap<&str, &AgentGraphNode>,
1107    node_id: &str,
1108    possible_ancestor: &str,
1109) -> bool {
1110    let mut pending = vec![node_id];
1111    let mut seen = BTreeSet::new();
1112    while let Some(current) = pending.pop() {
1113        if !seen.insert(current) {
1114            continue;
1115        }
1116        let Some(node) = nodes.get(current) else {
1117            continue;
1118        };
1119        for dependency in &node.dependencies {
1120            if dependency == possible_ancestor {
1121                return true;
1122            }
1123            pending.push(dependency);
1124        }
1125    }
1126    false
1127}
1128
1129fn valid_node_id(value: &str) -> bool {
1130    !value.is_empty()
1131        && value.len() <= 96
1132        && value
1133            .bytes()
1134            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
1135}
1136
1137/// Fail-closed graph planning, scheduling, or receipt validation error.
1138#[derive(Debug, Error, PartialEq, Eq)]
1139pub enum AgentGraphError {
1140    /// Public schema is unsupported.
1141    #[error("unsupported agent graph schema: {0}")]
1142    UnsupportedSchema(String),
1143    /// Graph protocol is unsupported.
1144    #[error("unsupported agent graph protocol: {0}")]
1145    UnsupportedProtocol(String),
1146    /// Parent task contract failed validation.
1147    #[error("agent graph parent contract is invalid")]
1148    InvalidParentContract,
1149    /// Parent identity, digest, confirmation, or state binding differs.
1150    #[error("agent graph does not match the parent contract or state")]
1151    ParentBinding,
1152    /// Graph authority is not a coordinator.
1153    #[error("agent graph coordinator authority has the wrong role")]
1154    CoordinatorRole,
1155    /// Aggregate graph limits are zero or inconsistent.
1156    #[error("agent graph budget is invalid")]
1157    InvalidGraphBudget,
1158    /// Node count is empty or exceeds the reservation.
1159    #[error("agent graph node limit is invalid or exceeded")]
1160    NodeLimit,
1161    /// Serialized node order is not canonical.
1162    #[error("agent graph nodes are not in canonical node-ID order")]
1163    NonCanonicalNodeOrder,
1164    /// More than one node uses the same ID.
1165    #[error("agent graph contains a duplicate node ID")]
1166    DuplicateNodeId,
1167    /// Node ID is empty, oversized, or not portable.
1168    #[error("invalid agent graph node ID: {0}")]
1169    InvalidNodeId(String),
1170    /// Node role conflicts with its recipe.
1171    #[error("agent graph node has an invalid role: {0}")]
1172    NodeRole(String),
1173    /// Node action or workspace authority is unsafe.
1174    #[error("agent graph node has invalid action or workspace authority: {0}")]
1175    NodeAuthority(String),
1176    /// Agent identity or authority digest is reused.
1177    #[error("agent graph reuses an authority: {0}")]
1178    AuthorityReused(String),
1179    /// A dependency does not exist or points to the node itself.
1180    #[error("node {node_id} references unknown dependency {dependency}")]
1181    UnknownDependency {
1182        /// Node containing the invalid dependency.
1183        node_id: String,
1184        /// Missing or self-referential dependency.
1185        dependency: String,
1186    },
1187    /// Dependency graph is cyclic.
1188    #[error("agent graph contains a dependency cycle")]
1189    Cycle,
1190    /// Reviewer/adversary target is missing or not a worker.
1191    #[error("review/adversary node has an invalid target: {0}")]
1192    ReviewTarget(String),
1193    /// Worker lacks an exact reviewer plus adversary barrier.
1194    #[error("worker lacks a complete reviewer/adversary proof barrier: {0}")]
1195    IncompleteProofBarrier(String),
1196    /// Delegated criterion does not exist in the parent.
1197    #[error("agent graph criterion is unknown: {0}")]
1198    UnknownCriterion(String),
1199    /// Parent criterion is owned by multiple workers.
1200    #[error("agent graph criterion is assigned more than once: {0}")]
1201    CriterionAssignedTwice(String),
1202    /// Some parent criterion has no worker owner.
1203    #[error("agent graph does not assign every parent criterion exactly once")]
1204    IncompleteDecomposition,
1205    /// A node write scope is not contained by a read scope.
1206    #[error("node {node_id} writes outside its read scope: {path}")]
1207    WriteOutsideReadScope {
1208        /// Node with the invalid scope.
1209        node_id: String,
1210        /// Invalid write scope.
1211        path: String,
1212    },
1213    /// Concurrent sibling write scopes overlap.
1214    #[error(
1215        "parallel siblings {left_node_id} ({left_scope}) and {right_node_id} ({right_scope}) have overlapping write scopes"
1216    )]
1217    SiblingWriteConflict {
1218        /// First sibling node.
1219        left_node_id: String,
1220        /// Second sibling node.
1221        right_node_id: String,
1222        /// First overlapping scope.
1223        left_scope: String,
1224        /// Second overlapping scope.
1225        right_scope: String,
1226    },
1227    /// Sum of node reservations exceeds the graph reservation.
1228    #[error("agent graph aggregate budget is exceeded")]
1229    GraphBudgetExceeded,
1230    /// Decision material contains malformed identities or digests.
1231    #[error("scheduler decision is invalid")]
1232    InvalidDecision,
1233    /// Decision does not represent an allowed state transition.
1234    #[error("scheduler state transition is invalid")]
1235    InvalidTransition,
1236    /// Decision digest is malformed or mismatched.
1237    #[error("scheduler decision digest is invalid")]
1238    DecisionDigest,
1239    /// Decision sequence, previous hash, or source state is inconsistent.
1240    #[error("scheduler decision chain is invalid")]
1241    DecisionChain,
1242    /// Terminal states or claimed outcome are inconsistent.
1243    #[error("scheduler terminal states are invalid")]
1244    TerminalStates,
1245    /// Receipt does not bind the supplied graph and workspace.
1246    #[error("scheduler receipt is not bound to its graph")]
1247    ReceiptBinding,
1248    /// Canonical JSON material could not be serialized.
1249    #[error("agent graph contract serialization failed")]
1250    Serialization,
1251    /// Lower-level P3A delegation contract failed.
1252    #[error(transparent)]
1253    Delegation(#[from] AgentError),
1254}
1255
1256#[cfg(test)]
1257mod tests {
1258    use super::*;
1259    use crate::{ClaimScope, Criterion};
1260
1261    fn parent_contract() -> TaskContract {
1262        let mut criterion = Criterion::required("result", "produce result.txt");
1263        criterion.evidence_requirement.allowed_producers = BTreeSet::from(["fs.patch".to_owned()]);
1264        criterion.evidence_requirement.freshness =
1265            crate::EvidenceFreshness::FinalWorkspaceGeneration;
1266        let mut contract = TaskContract::new("produce result", vec![criterion]);
1267        contract.confirmed = true;
1268        contract.claim_scope = ClaimScope::Task;
1269        contract
1270    }
1271
1272    fn authority(role: AgentRole, name: &str) -> AgentAuthority {
1273        AgentAuthority::new(role, name, name, "model", None).unwrap()
1274    }
1275
1276    fn node(
1277        node_id: &str,
1278        role: AgentRole,
1279        target: Option<&str>,
1280        criteria: &[&str],
1281    ) -> AgentGraphNode {
1282        AgentGraphNode {
1283            node_id: node_id.to_owned(),
1284            role,
1285            dependencies: target
1286                .map(|target| BTreeSet::from([target.to_owned()]))
1287                .unwrap_or_default(),
1288            review_target_node_id: target.map(str::to_owned),
1289            delegated_criterion_ids: criteria.iter().map(|value| (*value).to_owned()).collect(),
1290            added_constraints: Vec::new(),
1291            authority: authority(role, node_id),
1292            read_paths: BTreeSet::from(["result.txt".to_owned()]),
1293            write_paths: if role == AgentRole::Worker {
1294                BTreeSet::from(["result.txt".to_owned()])
1295            } else {
1296                BTreeSet::new()
1297            },
1298            budget: AgentBudget {
1299                max_provider_turns: 2,
1300                max_tool_calls: 2,
1301                max_duration_ms: 1_000,
1302            },
1303            allowed_action_classes: if role == AgentRole::Worker {
1304                BTreeSet::from([ActionClass::WorkspaceWrite])
1305            } else {
1306                BTreeSet::from([ActionClass::Read])
1307            },
1308        }
1309    }
1310
1311    fn plan(parent: &TaskContract) -> AgentGraphPlan {
1312        AgentGraphPlan::new(
1313            Uuid::new_v4(),
1314            parent,
1315            "a".repeat(64),
1316            authority(AgentRole::Coordinator, "coordinator"),
1317            AgentGraphBudget {
1318                max_nodes: 3,
1319                max_concurrency: 2,
1320                max_provider_turns: 6,
1321                max_tool_calls: 6,
1322                max_duration_ms: 3_000,
1323            },
1324            vec![
1325                node("worker", AgentRole::Worker, None, &["result"]),
1326                node("reviewer", AgentRole::Reviewer, Some("worker"), &[]),
1327                node("adversary", AgentRole::Adversary, Some("worker"), &[]),
1328            ],
1329        )
1330        .unwrap()
1331    }
1332
1333    fn template_node(
1334        node_id: &str,
1335        role: AgentRole,
1336        target: Option<&str>,
1337        criteria: &[&str],
1338    ) -> AgentGraphNodeTemplate {
1339        AgentGraphNodeTemplate {
1340            node_id: node_id.to_owned(),
1341            role,
1342            dependencies: target
1343                .map(|target| BTreeSet::from([target.to_owned()]))
1344                .unwrap_or_default(),
1345            review_target_node_id: target.map(str::to_owned),
1346            delegated_criterion_ids: criteria.iter().map(|value| (*value).to_owned()).collect(),
1347            added_constraints: Vec::new(),
1348            authority: AgentAuthorityTemplate {
1349                profile: node_id.to_owned(),
1350                provider: "openai".to_owned(),
1351                model: "test-model".to_owned(),
1352                credential_id: Some("usability".to_owned()),
1353            },
1354            read_paths: BTreeSet::from(["result.txt".to_owned()]),
1355            write_paths: if role == AgentRole::Worker {
1356                BTreeSet::from(["result.txt".to_owned()])
1357            } else {
1358                BTreeSet::new()
1359            },
1360            budget: AgentBudget {
1361                max_provider_turns: 2,
1362                max_tool_calls: 2,
1363                max_duration_ms: 1_000,
1364            },
1365            allowed_action_classes: if role == AgentRole::Worker {
1366                BTreeSet::from([ActionClass::WorkspaceWrite])
1367            } else {
1368                BTreeSet::from([ActionClass::Read])
1369            },
1370        }
1371    }
1372
1373    fn template() -> AgentGraphTemplate {
1374        AgentGraphTemplate {
1375            schema_version: SCHEMA_VERSION.to_owned(),
1376            protocol_version: AGENT_GRAPH_PROTOCOL_VERSION.to_owned(),
1377            coordinator_authority: AgentAuthorityTemplate {
1378                profile: "coordinator".to_owned(),
1379                provider: "openai".to_owned(),
1380                model: "test-model".to_owned(),
1381                credential_id: Some("usability".to_owned()),
1382            },
1383            budget: AgentGraphBudget {
1384                max_nodes: 3,
1385                max_concurrency: 2,
1386                max_provider_turns: 6,
1387                max_tool_calls: 6,
1388                max_duration_ms: 3_000,
1389            },
1390            nodes: vec![
1391                template_node("worker", AgentRole::Worker, None, &["result"]),
1392                template_node("reviewer", AgentRole::Reviewer, Some("worker"), &[]),
1393                template_node("adversary", AgentRole::Adversary, Some("worker"), &[]),
1394            ],
1395        }
1396    }
1397
1398    fn cancelled_receipt(graph: &AgentGraphPlan, parent: &TaskContract) -> SchedulerReceipt {
1399        let mut previous = None;
1400        let mut decisions = Vec::new();
1401        for (sequence, node_id) in ["adversary", "reviewer", "worker"].into_iter().enumerate() {
1402            let decision = SchedulerDecision::new(
1403                graph.graph_id,
1404                sequence as u64,
1405                node_id,
1406                SchedulerDecisionKind::Cancel,
1407                AgentNodeState::Pending,
1408                AgentNodeState::Cancelled,
1409                None,
1410                None,
1411                None,
1412                None,
1413                Some("parent_cancelled".to_owned()),
1414                previous,
1415            )
1416            .unwrap();
1417            previous = Some(decision.hash.clone());
1418            decisions.push(decision);
1419        }
1420        SchedulerReceipt {
1421            schema_version: SCHEMA_VERSION.to_owned(),
1422            protocol_version: AGENT_GRAPH_PROTOCOL_VERSION.to_owned(),
1423            graph_hash: graph.digest(parent).unwrap(),
1424            decisions,
1425            terminal_node_states: BTreeMap::from([
1426                ("adversary".to_owned(), AgentNodeState::Cancelled),
1427                ("reviewer".to_owned(), AgentNodeState::Cancelled),
1428                ("worker".to_owned(), AgentNodeState::Cancelled),
1429            ]),
1430            outcome: AgentGraphOutcome::Cancelled,
1431            final_workspace_generation: graph.parent_state_binding.clone(),
1432        }
1433    }
1434
1435    #[test]
1436    fn graph_plan_requires_decomposition_review_barriers_and_budget() {
1437        let parent = parent_contract();
1438        let graph = plan(&parent);
1439        graph.validate(&parent).unwrap();
1440        assert_eq!(graph.digest(&parent).unwrap().len(), 64);
1441
1442        let mut missing_adversary = graph.clone();
1443        missing_adversary
1444            .nodes
1445            .retain(|node| node.role != AgentRole::Adversary);
1446        assert_eq!(
1447            missing_adversary.validate(&parent),
1448            Err(AgentGraphError::IncompleteProofBarrier("worker".to_owned()))
1449        );
1450
1451        let mut excessive_budget = graph.clone();
1452        excessive_budget.budget.max_tool_calls = 5;
1453        assert_eq!(
1454            excessive_budget.validate(&parent),
1455            Err(AgentGraphError::GraphBudgetExceeded)
1456        );
1457
1458        let mut cycle = graph.clone();
1459        cycle
1460            .nodes
1461            .iter_mut()
1462            .find(|node| node.node_id == "worker")
1463            .unwrap()
1464            .dependencies
1465            .insert("reviewer".to_owned());
1466        assert_eq!(cycle.validate(&parent), Err(AgentGraphError::Cycle));
1467    }
1468
1469    #[test]
1470    fn graph_plan_rejects_sibling_write_conflicts() {
1471        let mut parent = parent_contract();
1472        let mut second_criterion = Criterion::required("second", "produce nested result");
1473        second_criterion.evidence_requirement.allowed_producers =
1474            BTreeSet::from(["fs.create".to_owned()]);
1475        second_criterion.evidence_requirement.freshness =
1476            crate::EvidenceFreshness::FinalWorkspaceGeneration;
1477        parent.criteria.push(second_criterion);
1478        parent.validate().unwrap();
1479        let mut second = node("worker-two", AgentRole::Worker, None, &["second"]);
1480        second.write_paths = BTreeSet::from(["result.txt/child".to_owned()]);
1481        let result = AgentGraphPlan::new(
1482            Uuid::new_v4(),
1483            &parent,
1484            "a".repeat(64),
1485            authority(AgentRole::Coordinator, "coordinator"),
1486            AgentGraphBudget {
1487                max_nodes: 6,
1488                max_concurrency: 2,
1489                max_provider_turns: 12,
1490                max_tool_calls: 12,
1491                max_duration_ms: 6_000,
1492            },
1493            vec![
1494                node("worker", AgentRole::Worker, None, &["result"]),
1495                node("reviewer", AgentRole::Reviewer, Some("worker"), &[]),
1496                node("adversary", AgentRole::Adversary, Some("worker"), &[]),
1497                second,
1498                node("reviewer-two", AgentRole::Reviewer, Some("worker-two"), &[]),
1499                node(
1500                    "adversary-two",
1501                    AgentRole::Adversary,
1502                    Some("worker-two"),
1503                    &[],
1504                ),
1505            ],
1506        );
1507        assert!(matches!(
1508            result,
1509            Err(AgentGraphError::SiblingWriteConflict { .. })
1510        ));
1511    }
1512
1513    #[test]
1514    fn scheduler_receipt_rejects_duplicate_reordered_and_tampered_decisions() {
1515        let parent = parent_contract();
1516        let graph = plan(&parent);
1517        let receipt = cancelled_receipt(&graph, &parent);
1518        receipt.validate(&graph, &parent).unwrap();
1519
1520        let mut duplicate = receipt.clone();
1521        duplicate
1522            .decisions
1523            .insert(1, duplicate.decisions[0].clone());
1524        assert_eq!(
1525            duplicate.validate(&graph, &parent),
1526            Err(AgentGraphError::DecisionChain)
1527        );
1528
1529        let mut reordered = receipt.clone();
1530        reordered.decisions.swap(0, 1);
1531        assert_eq!(
1532            reordered.validate(&graph, &parent),
1533            Err(AgentGraphError::DecisionChain)
1534        );
1535
1536        let mut tampered = receipt;
1537        tampered.decisions[1].reason_code = Some("forged".to_owned());
1538        assert_eq!(
1539            tampered.validate(&graph, &parent),
1540            Err(AgentGraphError::DecisionDigest)
1541        );
1542    }
1543
1544    #[test]
1545    fn graph_template_mints_fresh_authorities_and_binds_exact_parent() {
1546        let parent = parent_contract();
1547        let parent_session_id = Uuid::new_v4();
1548        let graph_template = template();
1549        let first = graph_template
1550            .bind(parent_session_id, &parent, "a".repeat(64))
1551            .unwrap();
1552        let second = graph_template
1553            .bind(parent_session_id, &parent, "a".repeat(64))
1554            .unwrap();
1555
1556        first.validate(&parent).unwrap();
1557        second.validate(&parent).unwrap();
1558        assert_ne!(first.graph_id, second.graph_id);
1559        assert_ne!(
1560            first.coordinator_authority.agent_id,
1561            second.coordinator_authority.agent_id
1562        );
1563        assert!(
1564            first
1565                .nodes
1566                .iter()
1567                .zip(&second.nodes)
1568                .all(|(left, right)| left.authority.agent_id != right.authority.agent_id)
1569        );
1570        assert_eq!(first.parent_session_id, parent_session_id);
1571        assert_eq!(first.parent_contract_id, parent.id);
1572        assert_eq!(first.parent_state_binding, "a".repeat(64));
1573
1574        let mut value = serde_json::to_value(graph_template).unwrap();
1575        value["unexpected"] = serde_json::json!(true);
1576        assert!(serde_json::from_value::<AgentGraphTemplate>(value).is_err());
1577    }
1578
1579    #[test]
1580    fn graph_json_rejects_unknown_fields() {
1581        let parent = parent_contract();
1582        let graph = plan(&parent);
1583        let mut value = serde_json::to_value(graph).unwrap();
1584        value["unexpected"] = serde_json::json!(true);
1585        assert!(serde_json::from_value::<AgentGraphPlan>(value).is_err());
1586    }
1587}