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