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    /// Operator-facing failure detail for terminal failure transitions.
772    #[serde(skip_serializing_if = "Option::is_none")]
773    pub detail: Option<String>,
774    /// Hash of the immediately preceding scheduler decision.
775    #[serde(skip_serializing_if = "Option::is_none")]
776    pub previous_hash: Option<String>,
777    /// Canonical digest over all preceding decision fields.
778    pub hash: String,
779}
780
781impl SchedulerDecision {
782    /// Constructs and hashes one deterministic transition.
783    #[allow(clippy::too_many_arguments)]
784    pub fn new(
785        graph_id: Uuid,
786        sequence: u64,
787        node_id: impl Into<String>,
788        kind: SchedulerDecisionKind,
789        from_state: AgentNodeState,
790        to_state: AgentNodeState,
791        session_id: Option<Uuid>,
792        delegation_id: Option<Uuid>,
793        receipt_hash: Option<String>,
794        workspace_generation: Option<String>,
795        reason_code: Option<String>,
796        previous_hash: Option<String>,
797        detail: Option<String>,
798    ) -> Result<Self, AgentGraphError> {
799        let mut decision = Self {
800            schema_version: SCHEMA_VERSION.to_owned(),
801            protocol_version: AGENT_GRAPH_PROTOCOL_VERSION.to_owned(),
802            graph_id,
803            sequence,
804            node_id: node_id.into(),
805            kind,
806            from_state,
807            to_state,
808            session_id,
809            delegation_id,
810            receipt_hash,
811            workspace_generation,
812            reason_code,
813            previous_hash,
814            detail,
815            hash: String::new(),
816        };
817        decision.validate_material()?;
818        decision.hash = decision.material_digest();
819        Ok(decision)
820    }
821
822    /// Validates schema, transition semantics, and its canonical digest.
823    pub fn validate(&self) -> Result<(), AgentGraphError> {
824        self.validate_material()?;
825        if !valid_digest(&self.hash) || self.hash != self.material_digest() {
826            return Err(AgentGraphError::DecisionDigest);
827        }
828        Ok(())
829    }
830
831    fn validate_material(&self) -> Result<(), AgentGraphError> {
832        if self.schema_version != SCHEMA_VERSION {
833            return Err(AgentGraphError::UnsupportedSchema(
834                self.schema_version.clone(),
835            ));
836        }
837        if self.protocol_version != AGENT_GRAPH_PROTOCOL_VERSION {
838            return Err(AgentGraphError::UnsupportedProtocol(
839                self.protocol_version.clone(),
840            ));
841        }
842        if !valid_node_id(&self.node_id)
843            || self
844                .previous_hash
845                .as_deref()
846                .is_some_and(|value| !valid_digest(value))
847            || self
848                .receipt_hash
849                .as_deref()
850                .is_some_and(|value| !valid_digest(value))
851            || self
852                .workspace_generation
853                .as_deref()
854                .is_some_and(|value| !valid_digest(value))
855            || self
856                .reason_code
857                .as_deref()
858                .is_some_and(|value| value.trim().is_empty())
859            || self
860                .detail
861                .as_deref()
862                .is_some_and(|value| value.trim().is_empty())
863        {
864            return Err(AgentGraphError::InvalidDecision);
865        }
866        let valid = match self.kind {
867            SchedulerDecisionKind::Start => {
868                self.from_state == AgentNodeState::Pending
869                    && self.to_state == AgentNodeState::Running
870                    && self.session_id.is_none()
871                    && self.receipt_hash.is_none()
872                    && self.workspace_generation.is_some()
873                    && self.reason_code.is_none()
874            }
875            SchedulerDecisionKind::HandoffRecorded => {
876                self.from_state == AgentNodeState::Running
877                    && self.to_state == AgentNodeState::HandoffReady
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::ReviewRecorded => {
885                self.from_state == AgentNodeState::Running
886                    && self.to_state == AgentNodeState::AwaitingMerge
887                    && self.session_id.is_some()
888                    && self.delegation_id.is_some()
889                    && self.receipt_hash.is_some()
890                    && self.workspace_generation.is_some()
891                    && self.reason_code.is_none()
892            }
893            SchedulerDecisionKind::MergeCommitted => {
894                matches!(
895                    self.from_state,
896                    AgentNodeState::HandoffReady | AgentNodeState::AwaitingMerge
897                ) && self.to_state == AgentNodeState::Succeeded
898                    && self.session_id.is_none()
899                    && self.delegation_id.is_some()
900                    && self.receipt_hash.is_some()
901                    && self.workspace_generation.is_some()
902                    && self
903                        .reason_code
904                        .as_deref()
905                        .is_none_or(|reason| reason == "recovered_persisted_merge")
906            }
907            SchedulerDecisionKind::Block => {
908                !self.from_state.is_terminal()
909                    && self.to_state == AgentNodeState::Blocked
910                    && self.reason_code.is_some()
911            }
912            SchedulerDecisionKind::Fail => {
913                !self.from_state.is_terminal()
914                    && self.to_state == AgentNodeState::Failed
915                    && self.reason_code.is_some()
916            }
917            SchedulerDecisionKind::Cancel => {
918                !self.from_state.is_terminal()
919                    && self.to_state == AgentNodeState::Cancelled
920                    && self.reason_code.is_some()
921            }
922            SchedulerDecisionKind::Invalidate => {
923                !self.from_state.is_terminal()
924                    && self.to_state == AgentNodeState::Invalidated
925                    && self.reason_code.is_some()
926            }
927        };
928        if !valid {
929            return Err(AgentGraphError::InvalidTransition);
930        }
931        Ok(())
932    }
933
934    fn material_digest(&self) -> String {
935        let mut material = serde_json::json!({
936            "schemaVersion": self.schema_version,
937            "protocolVersion": self.protocol_version,
938            "graphId": self.graph_id,
939            "sequence": self.sequence,
940            "nodeId": self.node_id,
941            "kind": self.kind,
942            "fromState": self.from_state,
943            "toState": self.to_state,
944            "sessionId": self.session_id,
945            "delegationId": self.delegation_id,
946            "receiptHash": self.receipt_hash,
947            "workspaceGeneration": self.workspace_generation,
948            "reasonCode": self.reason_code,
949            "previousHash": self.previous_hash,
950        });
951        if let Some(detail) = &self.detail {
952            material["detail"] = serde_json::json!(detail);
953        }
954        hash_json(&material)
955    }
956}
957
958/// Terminal outcome of one complete agent graph.
959#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
960#[serde(rename_all = "snake_case")]
961pub enum AgentGraphOutcome {
962    /// Every node and proof barrier completed.
963    Verified,
964    /// At least one visible prerequisite or policy gate blocked.
965    Blocked,
966    /// At least one node failed or was invalidated.
967    Failed,
968    /// Cancellation propagated into the graph.
969    Cancelled,
970}
971
972/// Offline-verifiable terminal scheduler receipt.
973#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
974#[serde(rename_all = "camelCase", deny_unknown_fields)]
975pub struct SchedulerReceipt {
976    /// Public Proofborne schema identifier.
977    pub schema_version: String,
978    /// Agent-graph protocol identifier.
979    pub protocol_version: String,
980    /// Canonical digest of the immutable graph plan.
981    pub graph_hash: String,
982    /// Complete hash-chained decision history.
983    pub decisions: Vec<SchedulerDecision>,
984    /// Final state for every graph node.
985    pub terminal_node_states: BTreeMap<String, AgentNodeState>,
986    /// Recomputed graph outcome.
987    pub outcome: AgentGraphOutcome,
988    /// Parent workspace generation after terminal fan-in.
989    pub final_workspace_generation: String,
990}
991
992impl SchedulerReceipt {
993    /// Replays the decision chain and verifies the claimed terminal outcome.
994    pub fn validate(
995        &self,
996        graph: &AgentGraphPlan,
997        parent: &TaskContract,
998    ) -> Result<(), AgentGraphError> {
999        graph.validate(parent)?;
1000        if self.schema_version != SCHEMA_VERSION {
1001            return Err(AgentGraphError::UnsupportedSchema(
1002                self.schema_version.clone(),
1003            ));
1004        }
1005        if self.protocol_version != AGENT_GRAPH_PROTOCOL_VERSION {
1006            return Err(AgentGraphError::UnsupportedProtocol(
1007                self.protocol_version.clone(),
1008            ));
1009        }
1010        if self.graph_hash != graph.digest(parent)?
1011            || !valid_digest(&self.final_workspace_generation)
1012        {
1013            return Err(AgentGraphError::ReceiptBinding);
1014        }
1015        let mut states: BTreeMap<String, AgentNodeState> = graph
1016            .nodes
1017            .iter()
1018            .map(|node| (node.node_id.clone(), AgentNodeState::Pending))
1019            .collect();
1020        let mut previous_hash = None;
1021        for (sequence, decision) in self.decisions.iter().enumerate() {
1022            decision.validate()?;
1023            if decision.graph_id != graph.graph_id
1024                || decision.sequence != sequence as u64
1025                || decision.previous_hash != previous_hash
1026            {
1027                return Err(AgentGraphError::DecisionChain);
1028            }
1029            let node = graph
1030                .node(&decision.node_id)
1031                .ok_or_else(|| AgentGraphError::InvalidNodeId(decision.node_id.clone()))?;
1032            let kind_matches_role = match decision.kind {
1033                SchedulerDecisionKind::HandoffRecorded => node.role == AgentRole::Worker,
1034                SchedulerDecisionKind::ReviewRecorded => {
1035                    matches!(node.role, AgentRole::Reviewer | AgentRole::Adversary)
1036                }
1037                _ => true,
1038            };
1039            if !kind_matches_role {
1040                return Err(AgentGraphError::InvalidTransition);
1041            }
1042            if decision.kind == SchedulerDecisionKind::Start {
1043                let ready = match node.role {
1044                    AgentRole::Worker => node.dependencies.iter().all(|dependency| {
1045                        states.get(dependency) == Some(&AgentNodeState::Succeeded)
1046                    }),
1047                    AgentRole::Reviewer | AgentRole::Adversary => {
1048                        node.review_target_node_id.as_ref().is_some_and(|target| {
1049                            states.get(target) == Some(&AgentNodeState::HandoffReady)
1050                        })
1051                    }
1052                    AgentRole::Coordinator => false,
1053                };
1054                if !ready {
1055                    return Err(AgentGraphError::DecisionChain);
1056                }
1057            }
1058            let state = states
1059                .get_mut(&decision.node_id)
1060                .ok_or_else(|| AgentGraphError::InvalidNodeId(decision.node_id.clone()))?;
1061            if *state != decision.from_state {
1062                return Err(AgentGraphError::DecisionChain);
1063            }
1064            *state = decision.to_state;
1065            previous_hash = Some(decision.hash.clone());
1066        }
1067        let expected_final_generation = self
1068            .decisions
1069            .iter()
1070            .rev()
1071            .find(|decision| decision.kind == SchedulerDecisionKind::MergeCommitted)
1072            .and_then(|decision| decision.workspace_generation.as_deref())
1073            .unwrap_or(graph.parent_state_binding.as_str());
1074        if self.final_workspace_generation != expected_final_generation {
1075            return Err(AgentGraphError::ReceiptBinding);
1076        }
1077        if states != self.terminal_node_states || states.values().any(|state| !state.is_terminal())
1078        {
1079            return Err(AgentGraphError::TerminalStates);
1080        }
1081        let expected = if states
1082            .values()
1083            .all(|state| *state == AgentNodeState::Succeeded)
1084        {
1085            AgentGraphOutcome::Verified
1086        } else if states
1087            .values()
1088            .any(|state| *state == AgentNodeState::Cancelled)
1089        {
1090            AgentGraphOutcome::Cancelled
1091        } else if states
1092            .values()
1093            .any(|state| matches!(state, AgentNodeState::Failed | AgentNodeState::Invalidated))
1094        {
1095            AgentGraphOutcome::Failed
1096        } else {
1097            AgentGraphOutcome::Blocked
1098        };
1099        if self.outcome != expected {
1100            return Err(AgentGraphError::TerminalStates);
1101        }
1102        Ok(())
1103    }
1104
1105    /// Computes the canonical terminal receipt digest.
1106    pub fn digest(
1107        &self,
1108        graph: &AgentGraphPlan,
1109        parent: &TaskContract,
1110    ) -> Result<String, AgentGraphError> {
1111        self.validate(graph, parent)?;
1112        Ok(hash_json(
1113            &serde_json::to_value(self).map_err(|_| AgentGraphError::Serialization)?,
1114        ))
1115    }
1116}
1117
1118fn depends_on(
1119    nodes: &BTreeMap<&str, &AgentGraphNode>,
1120    node_id: &str,
1121    possible_ancestor: &str,
1122) -> bool {
1123    let mut pending = vec![node_id];
1124    let mut seen = BTreeSet::new();
1125    while let Some(current) = pending.pop() {
1126        if !seen.insert(current) {
1127            continue;
1128        }
1129        let Some(node) = nodes.get(current) else {
1130            continue;
1131        };
1132        for dependency in &node.dependencies {
1133            if dependency == possible_ancestor {
1134                return true;
1135            }
1136            pending.push(dependency);
1137        }
1138    }
1139    false
1140}
1141
1142fn valid_node_id(value: &str) -> bool {
1143    !value.is_empty()
1144        && value.len() <= 96
1145        && value
1146            .bytes()
1147            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
1148}
1149
1150/// Fail-closed graph planning, scheduling, or receipt validation error.
1151#[derive(Debug, Error, PartialEq, Eq)]
1152pub enum AgentGraphError {
1153    /// Public schema is unsupported.
1154    #[error("unsupported agent graph schema: {0}")]
1155    UnsupportedSchema(String),
1156    /// Graph protocol is unsupported.
1157    #[error("unsupported agent graph protocol: {0}")]
1158    UnsupportedProtocol(String),
1159    /// Parent task contract failed validation.
1160    #[error("agent graph parent contract is invalid")]
1161    InvalidParentContract,
1162    /// Parent identity, digest, confirmation, or state binding differs.
1163    #[error("agent graph does not match the parent contract or state")]
1164    ParentBinding,
1165    /// Graph authority is not a coordinator.
1166    #[error("agent graph coordinator authority has the wrong role")]
1167    CoordinatorRole,
1168    /// Aggregate graph limits are zero or inconsistent.
1169    #[error("agent graph budget is invalid")]
1170    InvalidGraphBudget,
1171    /// Node count is empty or exceeds the reservation.
1172    #[error("agent graph node limit is invalid or exceeded")]
1173    NodeLimit,
1174    /// Serialized node order is not canonical.
1175    #[error("agent graph nodes are not in canonical node-ID order")]
1176    NonCanonicalNodeOrder,
1177    /// More than one node uses the same ID.
1178    #[error("agent graph contains a duplicate node ID")]
1179    DuplicateNodeId,
1180    /// Node ID is empty, oversized, or not portable.
1181    #[error("invalid agent graph node ID: {0}")]
1182    InvalidNodeId(String),
1183    /// Node role conflicts with its recipe.
1184    #[error("agent graph node has an invalid role: {0}")]
1185    NodeRole(String),
1186    /// Node action or workspace authority is unsafe.
1187    #[error("agent graph node has invalid action or workspace authority: {0}")]
1188    NodeAuthority(String),
1189    /// Agent identity or authority digest is reused.
1190    #[error("agent graph reuses an authority: {0}")]
1191    AuthorityReused(String),
1192    /// A dependency does not exist or points to the node itself.
1193    #[error("node {node_id} references unknown dependency {dependency}")]
1194    UnknownDependency {
1195        /// Node containing the invalid dependency.
1196        node_id: String,
1197        /// Missing or self-referential dependency.
1198        dependency: String,
1199    },
1200    /// Dependency graph is cyclic.
1201    #[error("agent graph contains a dependency cycle")]
1202    Cycle,
1203    /// Reviewer/adversary target is missing or not a worker.
1204    #[error("review/adversary node has an invalid target: {0}")]
1205    ReviewTarget(String),
1206    /// Worker lacks an exact reviewer plus adversary barrier.
1207    #[error("worker lacks a complete reviewer/adversary proof barrier: {0}")]
1208    IncompleteProofBarrier(String),
1209    /// Delegated criterion does not exist in the parent.
1210    #[error("agent graph criterion is unknown: {0}")]
1211    UnknownCriterion(String),
1212    /// Parent criterion is owned by multiple workers.
1213    #[error("agent graph criterion is assigned more than once: {0}")]
1214    CriterionAssignedTwice(String),
1215    /// Some parent criterion has no worker owner.
1216    #[error("agent graph does not assign every parent criterion exactly once")]
1217    IncompleteDecomposition,
1218    /// A node write scope is not contained by a read scope.
1219    #[error("node {node_id} writes outside its read scope: {path}")]
1220    WriteOutsideReadScope {
1221        /// Node with the invalid scope.
1222        node_id: String,
1223        /// Invalid write scope.
1224        path: String,
1225    },
1226    /// Concurrent sibling write scopes overlap.
1227    #[error(
1228        "parallel siblings {left_node_id} ({left_scope}) and {right_node_id} ({right_scope}) have overlapping write scopes"
1229    )]
1230    SiblingWriteConflict {
1231        /// First sibling node.
1232        left_node_id: String,
1233        /// Second sibling node.
1234        right_node_id: String,
1235        /// First overlapping scope.
1236        left_scope: String,
1237        /// Second overlapping scope.
1238        right_scope: String,
1239    },
1240    /// Sum of node reservations exceeds the graph reservation.
1241    #[error("agent graph aggregate budget is exceeded")]
1242    GraphBudgetExceeded,
1243    /// Decision material contains malformed identities or digests.
1244    #[error("scheduler decision is invalid")]
1245    InvalidDecision,
1246    /// Decision does not represent an allowed state transition.
1247    #[error("scheduler state transition is invalid")]
1248    InvalidTransition,
1249    /// Decision digest is malformed or mismatched.
1250    #[error("scheduler decision digest is invalid")]
1251    DecisionDigest,
1252    /// Decision sequence, previous hash, or source state is inconsistent.
1253    #[error("scheduler decision chain is invalid")]
1254    DecisionChain,
1255    /// Terminal states or claimed outcome are inconsistent.
1256    #[error("scheduler terminal states are invalid")]
1257    TerminalStates,
1258    /// Receipt does not bind the supplied graph and workspace.
1259    #[error("scheduler receipt is not bound to its graph")]
1260    ReceiptBinding,
1261    /// Canonical JSON material could not be serialized.
1262    #[error("agent graph contract serialization failed")]
1263    Serialization,
1264    /// Lower-level P3A delegation contract failed.
1265    #[error(transparent)]
1266    Delegation(#[from] AgentError),
1267}
1268
1269#[cfg(test)]
1270mod tests {
1271    use super::*;
1272    use crate::{ClaimScope, Criterion};
1273
1274    fn parent_contract() -> TaskContract {
1275        let mut criterion = Criterion::required("result", "produce result.txt");
1276        criterion.evidence_requirement.allowed_producers = BTreeSet::from(["fs.patch".to_owned()]);
1277        criterion.evidence_requirement.freshness =
1278            crate::EvidenceFreshness::FinalWorkspaceGeneration;
1279        let mut contract = TaskContract::new("produce result", vec![criterion]);
1280        contract.confirmed = true;
1281        contract.claim_scope = ClaimScope::Task;
1282        contract
1283    }
1284
1285    fn authority(role: AgentRole, name: &str) -> AgentAuthority {
1286        AgentAuthority::new(role, name, name, "model", None).unwrap()
1287    }
1288
1289    fn node(
1290        node_id: &str,
1291        role: AgentRole,
1292        target: Option<&str>,
1293        criteria: &[&str],
1294    ) -> AgentGraphNode {
1295        AgentGraphNode {
1296            node_id: node_id.to_owned(),
1297            role,
1298            dependencies: target
1299                .map(|target| BTreeSet::from([target.to_owned()]))
1300                .unwrap_or_default(),
1301            review_target_node_id: target.map(str::to_owned),
1302            delegated_criterion_ids: criteria.iter().map(|value| (*value).to_owned()).collect(),
1303            added_constraints: Vec::new(),
1304            authority: authority(role, node_id),
1305            read_paths: BTreeSet::from(["result.txt".to_owned()]),
1306            write_paths: if role == AgentRole::Worker {
1307                BTreeSet::from(["result.txt".to_owned()])
1308            } else {
1309                BTreeSet::new()
1310            },
1311            budget: AgentBudget {
1312                max_provider_turns: 2,
1313                max_tool_calls: 2,
1314                max_duration_ms: 1_000,
1315            },
1316            allowed_action_classes: if role == AgentRole::Worker {
1317                BTreeSet::from([ActionClass::WorkspaceWrite])
1318            } else {
1319                BTreeSet::from([ActionClass::Read])
1320            },
1321        }
1322    }
1323
1324    fn plan(parent: &TaskContract) -> AgentGraphPlan {
1325        AgentGraphPlan::new(
1326            Uuid::new_v4(),
1327            parent,
1328            "a".repeat(64),
1329            authority(AgentRole::Coordinator, "coordinator"),
1330            AgentGraphBudget {
1331                max_nodes: 3,
1332                max_concurrency: 2,
1333                max_provider_turns: 6,
1334                max_tool_calls: 6,
1335                max_duration_ms: 3_000,
1336            },
1337            vec![
1338                node("worker", AgentRole::Worker, None, &["result"]),
1339                node("reviewer", AgentRole::Reviewer, Some("worker"), &[]),
1340                node("adversary", AgentRole::Adversary, Some("worker"), &[]),
1341            ],
1342        )
1343        .unwrap()
1344    }
1345
1346    fn template_node(
1347        node_id: &str,
1348        role: AgentRole,
1349        target: Option<&str>,
1350        criteria: &[&str],
1351    ) -> AgentGraphNodeTemplate {
1352        AgentGraphNodeTemplate {
1353            node_id: node_id.to_owned(),
1354            role,
1355            dependencies: target
1356                .map(|target| BTreeSet::from([target.to_owned()]))
1357                .unwrap_or_default(),
1358            review_target_node_id: target.map(str::to_owned),
1359            delegated_criterion_ids: criteria.iter().map(|value| (*value).to_owned()).collect(),
1360            added_constraints: Vec::new(),
1361            authority: AgentAuthorityTemplate {
1362                profile: node_id.to_owned(),
1363                provider: "openai".to_owned(),
1364                model: "test-model".to_owned(),
1365                credential_id: Some("usability".to_owned()),
1366            },
1367            read_paths: BTreeSet::from(["result.txt".to_owned()]),
1368            write_paths: if role == AgentRole::Worker {
1369                BTreeSet::from(["result.txt".to_owned()])
1370            } else {
1371                BTreeSet::new()
1372            },
1373            budget: AgentBudget {
1374                max_provider_turns: 2,
1375                max_tool_calls: 2,
1376                max_duration_ms: 1_000,
1377            },
1378            allowed_action_classes: if role == AgentRole::Worker {
1379                BTreeSet::from([ActionClass::WorkspaceWrite])
1380            } else {
1381                BTreeSet::from([ActionClass::Read])
1382            },
1383        }
1384    }
1385
1386    fn template() -> AgentGraphTemplate {
1387        AgentGraphTemplate {
1388            schema_version: SCHEMA_VERSION.to_owned(),
1389            protocol_version: AGENT_GRAPH_PROTOCOL_VERSION.to_owned(),
1390            coordinator_authority: AgentAuthorityTemplate {
1391                profile: "coordinator".to_owned(),
1392                provider: "openai".to_owned(),
1393                model: "test-model".to_owned(),
1394                credential_id: Some("usability".to_owned()),
1395            },
1396            budget: AgentGraphBudget {
1397                max_nodes: 3,
1398                max_concurrency: 2,
1399                max_provider_turns: 6,
1400                max_tool_calls: 6,
1401                max_duration_ms: 3_000,
1402            },
1403            nodes: vec![
1404                template_node("worker", AgentRole::Worker, None, &["result"]),
1405                template_node("reviewer", AgentRole::Reviewer, Some("worker"), &[]),
1406                template_node("adversary", AgentRole::Adversary, Some("worker"), &[]),
1407            ],
1408        }
1409    }
1410
1411    fn cancelled_receipt(graph: &AgentGraphPlan, parent: &TaskContract) -> SchedulerReceipt {
1412        let mut previous = None;
1413        let mut decisions = Vec::new();
1414        for (sequence, node_id) in ["adversary", "reviewer", "worker"].into_iter().enumerate() {
1415            let decision = SchedulerDecision::new(
1416                graph.graph_id,
1417                sequence as u64,
1418                node_id,
1419                SchedulerDecisionKind::Cancel,
1420                AgentNodeState::Pending,
1421                AgentNodeState::Cancelled,
1422                None,
1423                None,
1424                None,
1425                None,
1426                Some("parent_cancelled".to_owned()),
1427                previous,
1428                None,
1429            )
1430            .unwrap();
1431            previous = Some(decision.hash.clone());
1432            decisions.push(decision);
1433        }
1434        SchedulerReceipt {
1435            schema_version: SCHEMA_VERSION.to_owned(),
1436            protocol_version: AGENT_GRAPH_PROTOCOL_VERSION.to_owned(),
1437            graph_hash: graph.digest(parent).unwrap(),
1438            decisions,
1439            terminal_node_states: BTreeMap::from([
1440                ("adversary".to_owned(), AgentNodeState::Cancelled),
1441                ("reviewer".to_owned(), AgentNodeState::Cancelled),
1442                ("worker".to_owned(), AgentNodeState::Cancelled),
1443            ]),
1444            outcome: AgentGraphOutcome::Cancelled,
1445            final_workspace_generation: graph.parent_state_binding.clone(),
1446        }
1447    }
1448
1449    #[test]
1450    fn graph_plan_requires_decomposition_review_barriers_and_budget() {
1451        let parent = parent_contract();
1452        let graph = plan(&parent);
1453        graph.validate(&parent).unwrap();
1454        assert_eq!(graph.digest(&parent).unwrap().len(), 64);
1455
1456        let mut missing_adversary = graph.clone();
1457        missing_adversary
1458            .nodes
1459            .retain(|node| node.role != AgentRole::Adversary);
1460        assert_eq!(
1461            missing_adversary.validate(&parent),
1462            Err(AgentGraphError::IncompleteProofBarrier("worker".to_owned()))
1463        );
1464
1465        let mut excessive_budget = graph.clone();
1466        excessive_budget.budget.max_tool_calls = 5;
1467        assert_eq!(
1468            excessive_budget.validate(&parent),
1469            Err(AgentGraphError::GraphBudgetExceeded)
1470        );
1471
1472        let mut cycle = graph.clone();
1473        cycle
1474            .nodes
1475            .iter_mut()
1476            .find(|node| node.node_id == "worker")
1477            .unwrap()
1478            .dependencies
1479            .insert("reviewer".to_owned());
1480        assert_eq!(cycle.validate(&parent), Err(AgentGraphError::Cycle));
1481    }
1482
1483    #[test]
1484    fn graph_plan_rejects_sibling_write_conflicts() {
1485        let mut parent = parent_contract();
1486        let mut second_criterion = Criterion::required("second", "produce nested result");
1487        second_criterion.evidence_requirement.allowed_producers =
1488            BTreeSet::from(["fs.create".to_owned()]);
1489        second_criterion.evidence_requirement.freshness =
1490            crate::EvidenceFreshness::FinalWorkspaceGeneration;
1491        parent.criteria.push(second_criterion);
1492        parent.validate().unwrap();
1493        let mut second = node("worker-two", AgentRole::Worker, None, &["second"]);
1494        second.write_paths = BTreeSet::from(["result.txt/child".to_owned()]);
1495        let result = AgentGraphPlan::new(
1496            Uuid::new_v4(),
1497            &parent,
1498            "a".repeat(64),
1499            authority(AgentRole::Coordinator, "coordinator"),
1500            AgentGraphBudget {
1501                max_nodes: 6,
1502                max_concurrency: 2,
1503                max_provider_turns: 12,
1504                max_tool_calls: 12,
1505                max_duration_ms: 6_000,
1506            },
1507            vec![
1508                node("worker", AgentRole::Worker, None, &["result"]),
1509                node("reviewer", AgentRole::Reviewer, Some("worker"), &[]),
1510                node("adversary", AgentRole::Adversary, Some("worker"), &[]),
1511                second,
1512                node("reviewer-two", AgentRole::Reviewer, Some("worker-two"), &[]),
1513                node(
1514                    "adversary-two",
1515                    AgentRole::Adversary,
1516                    Some("worker-two"),
1517                    &[],
1518                ),
1519            ],
1520        );
1521        assert!(matches!(
1522            result,
1523            Err(AgentGraphError::SiblingWriteConflict { .. })
1524        ));
1525    }
1526
1527    #[test]
1528    fn scheduler_receipt_rejects_duplicate_reordered_and_tampered_decisions() {
1529        let parent = parent_contract();
1530        let graph = plan(&parent);
1531        let receipt = cancelled_receipt(&graph, &parent);
1532        receipt.validate(&graph, &parent).unwrap();
1533
1534        let mut duplicate = receipt.clone();
1535        duplicate
1536            .decisions
1537            .insert(1, duplicate.decisions[0].clone());
1538        assert_eq!(
1539            duplicate.validate(&graph, &parent),
1540            Err(AgentGraphError::DecisionChain)
1541        );
1542
1543        let mut reordered = receipt.clone();
1544        reordered.decisions.swap(0, 1);
1545        assert_eq!(
1546            reordered.validate(&graph, &parent),
1547            Err(AgentGraphError::DecisionChain)
1548        );
1549
1550        let mut tampered = receipt;
1551        tampered.decisions[1].reason_code = Some("forged".to_owned());
1552        assert_eq!(
1553            tampered.validate(&graph, &parent),
1554            Err(AgentGraphError::DecisionDigest)
1555        );
1556    }
1557
1558    #[test]
1559    fn graph_template_mints_fresh_authorities_and_binds_exact_parent() {
1560        let parent = parent_contract();
1561        let parent_session_id = Uuid::new_v4();
1562        let graph_template = template();
1563        let first = graph_template
1564            .bind(parent_session_id, &parent, "a".repeat(64))
1565            .unwrap();
1566        let second = graph_template
1567            .bind(parent_session_id, &parent, "a".repeat(64))
1568            .unwrap();
1569
1570        first.validate(&parent).unwrap();
1571        second.validate(&parent).unwrap();
1572        assert_ne!(first.graph_id, second.graph_id);
1573        assert_ne!(
1574            first.coordinator_authority.agent_id,
1575            second.coordinator_authority.agent_id
1576        );
1577        assert!(
1578            first
1579                .nodes
1580                .iter()
1581                .zip(&second.nodes)
1582                .all(|(left, right)| left.authority.agent_id != right.authority.agent_id)
1583        );
1584        assert_eq!(first.parent_session_id, parent_session_id);
1585        assert_eq!(first.parent_contract_id, parent.id);
1586        assert_eq!(first.parent_state_binding, "a".repeat(64));
1587
1588        let mut value = serde_json::to_value(graph_template).unwrap();
1589        value["unexpected"] = serde_json::json!(true);
1590        assert!(serde_json::from_value::<AgentGraphTemplate>(value).is_err());
1591    }
1592
1593    #[test]
1594    fn graph_json_rejects_unknown_fields() {
1595        let parent = parent_contract();
1596        let graph = plan(&parent);
1597        let mut value = serde_json::to_value(graph).unwrap();
1598        value["unexpected"] = serde_json::json!(true);
1599        assert!(serde_json::from_value::<AgentGraphPlan>(value).is_err());
1600    }
1601}