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
14pub const AGENT_GRAPH_PROTOCOL_VERSION: &str = "proofborne.agent-graph.v1";
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "camelCase", deny_unknown_fields)]
20pub struct AgentGraphBudget {
21 pub max_nodes: usize,
23 pub max_concurrency: usize,
25 pub max_provider_turns: usize,
27 pub max_tool_calls: u64,
29 pub max_duration_ms: u64,
31}
32
33impl AgentGraphBudget {
34 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
51#[serde(rename_all = "camelCase", deny_unknown_fields)]
52pub struct AgentAuthorityTemplate {
53 pub profile: String,
55 pub provider: String,
57 pub model: String,
59 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79#[serde(rename_all = "camelCase", deny_unknown_fields)]
80pub struct AgentGraphNodeTemplate {
81 pub node_id: String,
83 pub role: AgentRole,
85 #[serde(default)]
87 pub dependencies: BTreeSet<String>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub review_target_node_id: Option<String>,
91 #[serde(default)]
93 pub delegated_criterion_ids: BTreeSet<String>,
94 #[serde(default)]
96 pub added_constraints: Vec<String>,
97 pub authority: AgentAuthorityTemplate,
99 pub read_paths: BTreeSet<String>,
101 #[serde(default)]
103 pub write_paths: BTreeSet<String>,
104 pub budget: AgentBudget,
106 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
130#[serde(rename_all = "camelCase", deny_unknown_fields)]
131pub struct AgentGraphTemplate {
132 pub schema_version: String,
134 pub protocol_version: String,
136 pub coordinator_authority: AgentAuthorityTemplate,
138 pub budget: AgentGraphBudget,
140 pub nodes: Vec<AgentGraphNodeTemplate>,
142}
143
144impl AgentGraphTemplate {
145 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
184#[serde(rename_all = "camelCase", deny_unknown_fields)]
185pub struct AgentGraphNode {
186 pub node_id: String,
188 pub role: AgentRole,
190 #[serde(default)]
192 pub dependencies: BTreeSet<String>,
193 #[serde(skip_serializing_if = "Option::is_none")]
195 pub review_target_node_id: Option<String>,
196 #[serde(default)]
198 pub delegated_criterion_ids: BTreeSet<String>,
199 #[serde(default)]
201 pub added_constraints: Vec<String>,
202 pub authority: AgentAuthority,
204 pub read_paths: BTreeSet<String>,
206 #[serde(default)]
208 pub write_paths: BTreeSet<String>,
209 pub budget: AgentBudget,
211 pub allowed_action_classes: BTreeSet<ActionClass>,
213}
214
215impl AgentGraphNode {
216 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
315#[serde(rename_all = "camelCase", deny_unknown_fields)]
316pub struct AgentGraphPlan {
317 pub schema_version: String,
319 pub protocol_version: String,
321 pub graph_id: Uuid,
323 pub parent_session_id: Uuid,
325 pub parent_contract_id: Uuid,
327 pub parent_contract_hash: String,
329 pub parent_state_binding: String,
331 pub coordinator_authority: AgentAuthority,
333 pub budget: AgentGraphBudget,
335 pub nodes: Vec<AgentGraphNode>,
337}
338
339impl AgentGraphPlan {
340 #[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 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 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 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 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#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
682#[serde(rename_all = "snake_case")]
683pub enum AgentNodeState {
684 Pending,
686 Running,
688 HandoffReady,
690 AwaitingMerge,
692 Succeeded,
694 Blocked,
696 Failed,
698 Cancelled,
700 Invalidated,
702}
703
704impl AgentNodeState {
705 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#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
716#[serde(rename_all = "snake_case")]
717pub enum SchedulerDecisionKind {
718 Start,
720 HandoffRecorded,
722 ReviewRecorded,
724 MergeCommitted,
726 Block,
728 Fail,
730 Cancel,
732 Invalidate,
734}
735
736#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
738#[serde(rename_all = "camelCase", deny_unknown_fields)]
739pub struct SchedulerDecision {
740 pub schema_version: String,
742 pub protocol_version: String,
744 pub graph_id: Uuid,
746 pub sequence: u64,
748 pub node_id: String,
750 pub kind: SchedulerDecisionKind,
752 pub from_state: AgentNodeState,
754 pub to_state: AgentNodeState,
756 #[serde(skip_serializing_if = "Option::is_none")]
758 pub session_id: Option<Uuid>,
759 #[serde(skip_serializing_if = "Option::is_none")]
761 pub delegation_id: Option<Uuid>,
762 #[serde(skip_serializing_if = "Option::is_none")]
764 pub receipt_hash: Option<String>,
765 #[serde(skip_serializing_if = "Option::is_none")]
767 pub workspace_generation: Option<String>,
768 #[serde(skip_serializing_if = "Option::is_none")]
770 pub reason_code: Option<String>,
771 #[serde(skip_serializing_if = "Option::is_none")]
773 pub detail: Option<String>,
774 #[serde(skip_serializing_if = "Option::is_none")]
776 pub previous_hash: Option<String>,
777 pub hash: String,
779}
780
781impl SchedulerDecision {
782 #[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 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#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
960#[serde(rename_all = "snake_case")]
961pub enum AgentGraphOutcome {
962 Verified,
964 Blocked,
966 Failed,
968 Cancelled,
970}
971
972#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
974#[serde(rename_all = "camelCase", deny_unknown_fields)]
975pub struct SchedulerReceipt {
976 pub schema_version: String,
978 pub protocol_version: String,
980 pub graph_hash: String,
982 pub decisions: Vec<SchedulerDecision>,
984 pub terminal_node_states: BTreeMap<String, AgentNodeState>,
986 pub outcome: AgentGraphOutcome,
988 pub final_workspace_generation: String,
990}
991
992impl SchedulerReceipt {
993 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 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#[derive(Debug, Error, PartialEq, Eq)]
1152pub enum AgentGraphError {
1153 #[error("unsupported agent graph schema: {0}")]
1155 UnsupportedSchema(String),
1156 #[error("unsupported agent graph protocol: {0}")]
1158 UnsupportedProtocol(String),
1159 #[error("agent graph parent contract is invalid")]
1161 InvalidParentContract,
1162 #[error("agent graph does not match the parent contract or state")]
1164 ParentBinding,
1165 #[error("agent graph coordinator authority has the wrong role")]
1167 CoordinatorRole,
1168 #[error("agent graph budget is invalid")]
1170 InvalidGraphBudget,
1171 #[error("agent graph node limit is invalid or exceeded")]
1173 NodeLimit,
1174 #[error("agent graph nodes are not in canonical node-ID order")]
1176 NonCanonicalNodeOrder,
1177 #[error("agent graph contains a duplicate node ID")]
1179 DuplicateNodeId,
1180 #[error("invalid agent graph node ID: {0}")]
1182 InvalidNodeId(String),
1183 #[error("agent graph node has an invalid role: {0}")]
1185 NodeRole(String),
1186 #[error("agent graph node has invalid action or workspace authority: {0}")]
1188 NodeAuthority(String),
1189 #[error("agent graph reuses an authority: {0}")]
1191 AuthorityReused(String),
1192 #[error("node {node_id} references unknown dependency {dependency}")]
1194 UnknownDependency {
1195 node_id: String,
1197 dependency: String,
1199 },
1200 #[error("agent graph contains a dependency cycle")]
1202 Cycle,
1203 #[error("review/adversary node has an invalid target: {0}")]
1205 ReviewTarget(String),
1206 #[error("worker lacks a complete reviewer/adversary proof barrier: {0}")]
1208 IncompleteProofBarrier(String),
1209 #[error("agent graph criterion is unknown: {0}")]
1211 UnknownCriterion(String),
1212 #[error("agent graph criterion is assigned more than once: {0}")]
1214 CriterionAssignedTwice(String),
1215 #[error("agent graph does not assign every parent criterion exactly once")]
1217 IncompleteDecomposition,
1218 #[error("node {node_id} writes outside its read scope: {path}")]
1220 WriteOutsideReadScope {
1221 node_id: String,
1223 path: String,
1225 },
1226 #[error(
1228 "parallel siblings {left_node_id} ({left_scope}) and {right_node_id} ({right_scope}) have overlapping write scopes"
1229 )]
1230 SiblingWriteConflict {
1231 left_node_id: String,
1233 right_node_id: String,
1235 left_scope: String,
1237 right_scope: String,
1239 },
1240 #[error("agent graph aggregate budget is exceeded")]
1242 GraphBudgetExceeded,
1243 #[error("scheduler decision is invalid")]
1245 InvalidDecision,
1246 #[error("scheduler state transition is invalid")]
1248 InvalidTransition,
1249 #[error("scheduler decision digest is invalid")]
1251 DecisionDigest,
1252 #[error("scheduler decision chain is invalid")]
1254 DecisionChain,
1255 #[error("scheduler terminal states are invalid")]
1257 TerminalStates,
1258 #[error("scheduler receipt is not bound to its graph")]
1260 ReceiptBinding,
1261 #[error("agent graph contract serialization failed")]
1263 Serialization,
1264 #[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}