1use std::collections::{BTreeMap, BTreeSet};
10use std::fmt;
11
12use serde::{Deserialize, Serialize};
13
14use crate::attestation::{Envelope, Verifier};
15use crate::merkle::{
16 verify_consistency, Checkpoint, CheckpointVerifyOutcome, MerkleTree, ProofFile,
17};
18use crate::statements::{action_in_scope, payload_type, ActionStatement, ReceiptStatement};
19use crate::trust::TrustRootStore;
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct WorkflowDeclaration {
25 pub kind: String,
26 pub schema_version: String,
27 pub workflow_id: String,
28 pub authority: String,
29 pub entry_node: String,
30 pub terminal_nodes: Vec<String>,
31 pub nodes: Vec<WorkflowNode>,
32 pub edges: Vec<WorkflowEdge>,
33 #[serde(default, skip_serializing_if = "Vec::is_empty")]
34 pub loops: Vec<WorkflowLoop>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct WorkflowNode {
40 pub id: String,
41 pub executor: ExecutorConstraint,
42 pub allowed_tools: Vec<String>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct ExecutorConstraint {
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub actor: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub capability: Option<String>,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct WorkflowEdge {
58 pub from: String,
59 pub to: String,
60 pub when: EdgeCondition,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum EdgeCondition {
66 Always,
67 OnPass,
68 OnFail,
69 OnRefused,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct WorkflowLoop {
75 pub id: String,
76 pub back_edge: EdgeRef,
77 pub max_iterations: u32,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub budget: Option<WorkflowBudget>,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct EdgeRef {
85 pub from: String,
86 pub to: String,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(deny_unknown_fields)]
91pub struct WorkflowBudget {
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub max_actions: Option<u32>,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum EvidenceGrade {
101 Checked,
102 Captured,
103 Asserted,
104}
105
106impl EvidenceGrade {
107 fn weakest(self, other: Self) -> Self {
108 use EvidenceGrade::{Asserted, Captured, Checked};
109 match (self, other) {
110 (Asserted, _) | (_, Asserted) => Asserted,
111 (Captured, _) | (_, Captured) => Captured,
112 (Checked, Checked) => Checked,
113 }
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(deny_unknown_fields)]
120pub struct ObservedWorkflowRun {
121 pub run_id: String,
122 pub status: WorkflowRunStatus,
123 pub workflow_ref: String,
124 pub pre_existence: PreExistenceEvidence,
125 pub attempts: Vec<ObservedNodeAttempt>,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(rename_all = "snake_case")]
130pub enum WorkflowRunStatus {
131 Completed,
132 Incomplete,
133 Failed,
134 Abandoned,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct PreExistenceEvidence {
143 pub grade: EvidenceGrade,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub reason: Option<String>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub declaration_checkpoint: Option<String>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub declaration_tree_size: Option<u64>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub first_run_leaf_index: Option<u64>,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub consistency_to: Option<String>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub declaration_signed_at: Option<String>,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub first_run_signed_at: Option<String>,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(deny_unknown_fields)]
162pub struct ObservedNodeAttempt {
163 pub node_id: String,
164 pub iteration: u32,
165 pub actor: String,
166 #[serde(default, skip_serializing_if = "Vec::is_empty")]
167 pub capabilities: Vec<String>,
168 #[serde(default, skip_serializing_if = "Vec::is_empty")]
169 pub tools: Vec<String>,
170 pub outcome: NodeOutcome,
171 pub grade: EvidenceGrade,
172 #[serde(default, skip_serializing_if = "Vec::is_empty")]
173 pub evidence: Vec<String>,
174 #[serde(default, skip_serializing_if = "Vec::is_empty")]
177 pub action_evidence: Vec<String>,
178 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
182 pub tool_evidence: BTreeMap<String, Vec<String>>,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(rename_all = "snake_case")]
187pub enum NodeOutcome {
188 Completed,
189 Pass,
190 Fail,
191 Refused,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195pub struct WorkflowConformanceReport {
196 pub run_id: String,
197 pub workflow_ref: String,
198 pub pre_existence: PreExistenceReport,
199 pub path: PathReport,
200 pub authority: WorkflowAuthorityReport,
201 pub loops: Vec<LoopReport>,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205pub struct PreExistenceReport {
206 pub grade: EvidenceGrade,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub reason: Option<String>,
209}
210
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212pub struct PathReport {
213 pub grade: EvidenceGrade,
214 pub deviations: Vec<PathDeviation>,
215 pub gaps: Vec<PathGap>,
216 #[serde(default, skip_serializing_if = "Vec::is_empty")]
217 pub asserted_edges: Vec<ObservedEdge>,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221pub struct PathDeviation {
222 pub from: String,
223 pub to: String,
224 pub reason: String,
225 pub evidence: Vec<String>,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229pub struct PathGap {
230 pub node_id: String,
231 pub reason: String,
232 pub after: String,
233 pub evidence: Vec<String>,
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237pub struct ObservedEdge {
238 pub from: String,
239 pub to: String,
240 pub evidence: Vec<String>,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct WorkflowAuthorityReport {
245 pub grade: EvidenceGrade,
246 pub deviations: Vec<AuthorityDeviation>,
247}
248
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct AuthorityDeviation {
251 pub node_id: String,
252 pub kind: String,
253 pub value: String,
254 pub evidence: Vec<String>,
255}
256
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258pub struct LoopReport {
259 pub id: String,
260 pub grade: EvidenceGrade,
261 pub iterations: u32,
262 pub max_iterations: u32,
263 pub limit_exceeded: bool,
264 pub budget_exceeded: bool,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
270#[serde(deny_unknown_fields)]
271pub struct WorkflowPreExistenceProof {
272 pub declaration: ProofFile,
273 pub first_run: ProofFile,
274 pub consistency_proof: Vec<String>,
275}
276
277#[derive(Debug, Clone, PartialEq, Eq)]
278pub enum WorkflowPreExistenceError {
279 ArtifactMismatch {
280 expected: String,
281 actual: String,
282 },
283 CheckpointNotTrusted {
284 which: String,
285 detail: String,
286 },
287 LogIdentityMismatch {
288 declaration_signer: String,
289 first_run_signer: String,
290 },
291 VersionMismatch {
292 declaration: u8,
293 first_run: u8,
294 },
295 InvalidRoot {
296 which: String,
297 },
298 InvalidInclusion {
299 which: String,
300 },
301 InvalidOrder {
302 detail: String,
303 },
304 InvalidConsistency,
305}
306
307impl fmt::Display for WorkflowPreExistenceError {
308 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309 match self {
310 Self::ArtifactMismatch { expected, actual } => {
311 write!(f, "proof artifact mismatch: expected {expected}, got {actual}")
312 }
313 Self::CheckpointNotTrusted { which, detail } => {
314 write!(f, "{which} checkpoint is not trusted: {detail}")
315 }
316 Self::LogIdentityMismatch {
317 declaration_signer,
318 first_run_signer,
319 } => write!(
320 f,
321 "checkpoints belong to different log identities: declaration signer {declaration_signer}, first run signer {first_run_signer}"
322 ),
323 Self::VersionMismatch {
324 declaration,
325 first_run,
326 } => write!(
327 f,
328 "checkpoint merkle versions differ: declaration v{declaration}, first run v{first_run}"
329 ),
330 Self::InvalidRoot { which } => {
331 write!(f, "{which} checkpoint root is not sha256:<hex>")
332 }
333 Self::InvalidInclusion { which } => {
334 write!(f, "{which} artifact inclusion proof is invalid")
335 }
336 Self::InvalidOrder { detail } => write!(f, "invalid workflow/run order: {detail}"),
337 Self::InvalidConsistency => write!(
338 f,
339 "later checkpoint does not cryptographically extend the declaration checkpoint"
340 ),
341 }
342 }
343}
344
345impl std::error::Error for WorkflowPreExistenceError {}
346
347#[derive(Debug, Clone, PartialEq, Eq)]
352pub enum WorkflowRunBindingError {
353 Signature(String),
354 ArtifactMismatch { expected: String, actual: String },
355 WrongPayloadType { actual: String },
356 InvalidAction(String),
357 NotSessionStart { actual: String },
358 MissingWorkflowRef,
359 WorkflowMismatch { expected: String, actual: String },
360}
361
362impl fmt::Display for WorkflowRunBindingError {
363 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
364 match self {
365 Self::Signature(detail) => write!(f, "first run signature is not trusted: {detail}"),
366 Self::ArtifactMismatch { expected, actual } => write!(
367 f,
368 "first run artifact mismatch: expected {expected}, verified {actual}"
369 ),
370 Self::WrongPayloadType { actual } => write!(
371 f,
372 "first run artifact has payload type `{actual}`, expected `{}`",
373 payload_type("action")
374 ),
375 Self::InvalidAction(detail) => {
376 write!(f, "first run artifact is not an action statement: {detail}")
377 }
378 Self::NotSessionStart { actual } => write!(
379 f,
380 "first run action is `{actual}`, expected `session.start`"
381 ),
382 Self::MissingWorkflowRef => {
383 write!(
384 f,
385 "first run session.start action has no workflow_ref binding"
386 )
387 }
388 Self::WorkflowMismatch { expected, actual } => write!(
389 f,
390 "first run workflow mismatch: expected {expected}, action binds {actual}"
391 ),
392 }
393 }
394}
395
396impl std::error::Error for WorkflowRunBindingError {}
397
398#[derive(Debug, Clone, PartialEq, Eq)]
399pub struct WorkflowValidationError {
400 pub field: String,
401 pub detail: String,
402}
403
404impl fmt::Display for WorkflowValidationError {
405 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
406 write!(f, "{}: {}", self.field, self.detail)
407 }
408}
409
410#[derive(Debug, Clone, PartialEq, Eq)]
411pub enum WorkflowConformanceError {
412 InvalidDeclaration(Vec<WorkflowValidationError>),
413 InvalidRun(String),
414}
415
416impl fmt::Display for WorkflowConformanceError {
417 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
418 match self {
419 Self::InvalidDeclaration(errors) => write!(
420 f,
421 "invalid workflow declaration: {}",
422 errors
423 .iter()
424 .map(ToString::to_string)
425 .collect::<Vec<_>>()
426 .join("; ")
427 ),
428 Self::InvalidRun(detail) => write!(f, "invalid observed workflow run: {detail}"),
429 }
430 }
431}
432
433impl std::error::Error for WorkflowConformanceError {}
434
435impl WorkflowDeclaration {
436 pub fn validate(&self) -> Result<(), Vec<WorkflowValidationError>> {
437 let mut errors = Vec::new();
438 if self.kind != "workflow.v1" {
439 invalid(&mut errors, "kind", "must be workflow.v1");
440 }
441 if self.schema_version != "1" {
442 invalid(&mut errors, "schema_version", "must be 1");
443 }
444 if self.workflow_id.trim().is_empty() {
445 invalid(&mut errors, "workflow_id", "must not be empty");
446 }
447 if self.authority.trim().is_empty() {
448 invalid(&mut errors, "authority", "must not be empty");
449 }
450 if self.nodes.is_empty() {
451 invalid(&mut errors, "nodes", "must contain at least one node");
452 }
453
454 let mut node_ids = BTreeSet::new();
455 for (index, node) in self.nodes.iter().enumerate() {
456 if node.id.trim().is_empty() {
457 invalid(
458 &mut errors,
459 &format!("nodes[{index}].id"),
460 "must not be empty",
461 );
462 } else if !node_ids.insert(node.id.clone()) {
463 invalid(
464 &mut errors,
465 &format!("nodes[{index}].id"),
466 "duplicates an earlier node id",
467 );
468 }
469 if node.executor.actor.is_some() == node.executor.capability.is_some() {
470 invalid(
471 &mut errors,
472 &format!("nodes[{index}].executor"),
473 "must contain exactly one of actor or capability",
474 );
475 }
476 if node
477 .executor
478 .actor
479 .as_deref()
480 .is_some_and(|actor| actor.trim().is_empty())
481 || node
482 .executor
483 .capability
484 .as_deref()
485 .is_some_and(|capability| capability.trim().is_empty())
486 {
487 invalid(
488 &mut errors,
489 &format!("nodes[{index}].executor"),
490 "actor or capability must not be empty",
491 );
492 }
493 let mut tools = BTreeSet::new();
494 for tool in &node.allowed_tools {
495 if tool.trim().is_empty() {
496 invalid(
497 &mut errors,
498 &format!("nodes[{index}].allowed_tools"),
499 "must not contain an empty tool",
500 );
501 } else if !tools.insert(tool) {
502 invalid(
503 &mut errors,
504 &format!("nodes[{index}].allowed_tools"),
505 &format!("contains duplicate tool {tool}"),
506 );
507 }
508 }
509 }
510
511 if !node_ids.contains(&self.entry_node) {
512 invalid(&mut errors, "entry_node", "must name a declared node");
513 }
514 if self.terminal_nodes.is_empty() {
515 invalid(
516 &mut errors,
517 "terminal_nodes",
518 "must contain at least one terminal",
519 );
520 }
521 let mut terminals = BTreeSet::new();
522 for terminal in &self.terminal_nodes {
523 if !node_ids.contains(terminal) {
524 invalid(
525 &mut errors,
526 "terminal_nodes",
527 &format!("unknown node {terminal}"),
528 );
529 }
530 if !terminals.insert(terminal) {
531 invalid(
532 &mut errors,
533 "terminal_nodes",
534 &format!("duplicate terminal {terminal}"),
535 );
536 }
537 }
538
539 let mut edge_keys = BTreeSet::new();
540 for (index, edge) in self.edges.iter().enumerate() {
541 if !node_ids.contains(&edge.from) {
542 invalid(
543 &mut errors,
544 &format!("edges[{index}].from"),
545 "must name a declared node",
546 );
547 }
548 if !node_ids.contains(&edge.to) {
549 invalid(
550 &mut errors,
551 &format!("edges[{index}].to"),
552 "must name a declared node",
553 );
554 }
555 if !edge_keys.insert((edge.from.clone(), edge.to.clone(), edge.when)) {
556 invalid(
557 &mut errors,
558 &format!("edges[{index}]"),
559 "duplicates an earlier edge and condition",
560 );
561 }
562 }
563
564 let declared_edge_refs: BTreeSet<EdgeRef> = self
565 .edges
566 .iter()
567 .map(|edge| EdgeRef {
568 from: edge.from.clone(),
569 to: edge.to.clone(),
570 })
571 .collect();
572 let mut loop_ids = BTreeSet::new();
573 let mut loop_edges = BTreeSet::new();
574 for (index, workflow_loop) in self.loops.iter().enumerate() {
575 if workflow_loop.id.trim().is_empty() {
576 invalid(
577 &mut errors,
578 &format!("loops[{index}].id"),
579 "must not be empty",
580 );
581 } else if !loop_ids.insert(workflow_loop.id.clone()) {
582 invalid(
583 &mut errors,
584 &format!("loops[{index}].id"),
585 "duplicates an earlier loop id",
586 );
587 }
588 if !declared_edge_refs.contains(&workflow_loop.back_edge) {
589 invalid(
590 &mut errors,
591 &format!("loops[{index}].back_edge"),
592 "must reference a declared edge",
593 );
594 }
595 if self
596 .edges
597 .iter()
598 .filter(|edge| {
599 edge.from == workflow_loop.back_edge.from
600 && edge.to == workflow_loop.back_edge.to
601 })
602 .count()
603 > 1
604 {
605 invalid(
606 &mut errors,
607 &format!("loops[{index}].back_edge"),
608 "is ambiguous because multiple conditions use this edge",
609 );
610 }
611 if !loop_edges.insert(workflow_loop.back_edge.clone()) {
612 invalid(
613 &mut errors,
614 &format!("loops[{index}].back_edge"),
615 "is already claimed by another loop",
616 );
617 }
618 if workflow_loop.max_iterations == 0 {
619 invalid(
620 &mut errors,
621 &format!("loops[{index}].max_iterations"),
622 "must be greater than zero",
623 );
624 }
625 if matches!(
626 workflow_loop.budget.as_ref().and_then(|b| b.max_actions),
627 Some(0)
628 ) {
629 invalid(
630 &mut errors,
631 &format!("loops[{index}].budget.max_actions"),
632 "must be greater than zero",
633 );
634 }
635 }
636
637 for (index, workflow_loop) in self.loops.iter().enumerate() {
638 if !edge_closes_cycle(
639 &workflow_loop.back_edge,
640 &node_ids,
641 &self.edges,
642 &loop_edges,
643 ) {
644 invalid(
645 &mut errors,
646 &format!("loops[{index}].back_edge"),
647 "must close a path through non-loop edges",
648 );
649 }
650 }
651 if contains_unbounded_cycle(&node_ids, &self.edges, &loop_edges) {
652 invalid(
653 &mut errors,
654 "edges",
655 "contains a cycle not declared as a bounded loop back edge",
656 );
657 }
658
659 if errors.is_empty() {
660 Ok(())
661 } else {
662 Err(errors)
663 }
664 }
665}
666
667pub fn verify_first_run_workflow_binding(
675 expected_workflow_ref: &str,
676 expected_first_run_artifact: &str,
677 envelope: &Envelope,
678 verifier: &Verifier,
679) -> Result<(), WorkflowRunBindingError> {
680 let verified = verifier
681 .verify(envelope)
682 .map_err(|e| WorkflowRunBindingError::Signature(e.to_string()))?;
683 if verified.artifact_id != expected_first_run_artifact {
684 return Err(WorkflowRunBindingError::ArtifactMismatch {
685 expected: expected_first_run_artifact.to_string(),
686 actual: verified.artifact_id,
687 });
688 }
689
690 let expected_payload_type = payload_type("action");
691 if verified.payload_type != expected_payload_type {
692 return Err(WorkflowRunBindingError::WrongPayloadType {
693 actual: verified.payload_type,
694 });
695 }
696
697 let action: ActionStatement = envelope
698 .unmarshal_statement()
699 .map_err(|e| WorkflowRunBindingError::InvalidAction(e.to_string()))?;
700 if action.action != "session.start" {
701 return Err(WorkflowRunBindingError::NotSessionStart {
702 actual: action.action,
703 });
704 }
705 let actual_workflow_ref = action
706 .meta
707 .as_ref()
708 .and_then(|meta| meta.get("workflow_ref"))
709 .and_then(serde_json::Value::as_str)
710 .ok_or(WorkflowRunBindingError::MissingWorkflowRef)?;
711 if actual_workflow_ref != expected_workflow_ref {
712 return Err(WorkflowRunBindingError::WorkflowMismatch {
713 expected: expected_workflow_ref.to_string(),
714 actual: actual_workflow_ref.to_string(),
715 });
716 }
717
718 Ok(())
719}
720
721pub fn verify_workflow_pre_existence(
729 proof: &WorkflowPreExistenceProof,
730 expected_workflow_ref: &str,
731 expected_first_run_artifact: &str,
732 trust: &TrustRootStore,
733) -> Result<PreExistenceEvidence, WorkflowPreExistenceError> {
734 require_artifact_id(expected_workflow_ref, &proof.declaration.artifact_id)?;
735 require_artifact_id(expected_first_run_artifact, &proof.first_run.artifact_id)?;
736
737 verify_checkpoint("declaration", &proof.declaration.checkpoint, trust)?;
738 verify_checkpoint("first run", &proof.first_run.checkpoint, trust)?;
739
740 let declaration_checkpoint = &proof.declaration.checkpoint;
741 let run_checkpoint = &proof.first_run.checkpoint;
742 if declaration_checkpoint.public_key != run_checkpoint.public_key {
743 return Err(WorkflowPreExistenceError::LogIdentityMismatch {
744 declaration_signer: declaration_checkpoint.signer.clone(),
745 first_run_signer: run_checkpoint.signer.clone(),
746 });
747 }
748 if declaration_checkpoint.merkle_version != run_checkpoint.merkle_version {
749 return Err(WorkflowPreExistenceError::VersionMismatch {
750 declaration: declaration_checkpoint.merkle_version,
751 first_run: run_checkpoint.merkle_version,
752 });
753 }
754
755 let declaration_root = checkpoint_root_hex("declaration", declaration_checkpoint)?;
756 let run_root = checkpoint_root_hex("first run", run_checkpoint)?;
757
758 if !MerkleTree::verify_proof_at_size(
759 declaration_checkpoint.merkle_version,
760 declaration_root,
761 &proof.declaration.artifact_id,
762 &proof.declaration.inclusion_proof,
763 declaration_checkpoint.tree_size,
764 ) {
765 return Err(WorkflowPreExistenceError::InvalidInclusion {
766 which: "declaration".into(),
767 });
768 }
769 if !MerkleTree::verify_proof_at_size(
770 run_checkpoint.merkle_version,
771 run_root,
772 &proof.first_run.artifact_id,
773 &proof.first_run.inclusion_proof,
774 run_checkpoint.tree_size,
775 ) {
776 return Err(WorkflowPreExistenceError::InvalidInclusion {
777 which: "first run".into(),
778 });
779 }
780
781 if declaration_checkpoint.tree_size >= run_checkpoint.tree_size {
782 return Err(WorkflowPreExistenceError::InvalidOrder {
783 detail: format!(
784 "declaration tree size {} is not earlier than run tree size {}",
785 declaration_checkpoint.tree_size, run_checkpoint.tree_size
786 ),
787 });
788 }
789 if proof.first_run.inclusion_proof.leaf_index < declaration_checkpoint.tree_size {
790 return Err(WorkflowPreExistenceError::InvalidOrder {
791 detail: format!(
792 "first run leaf {} is inside the declaration checkpoint prefix of size {}",
793 proof.first_run.inclusion_proof.leaf_index, declaration_checkpoint.tree_size
794 ),
795 });
796 }
797 if !verify_consistency(
798 declaration_checkpoint.merkle_version,
799 declaration_checkpoint.tree_size,
800 declaration_root,
801 run_checkpoint.tree_size,
802 run_root,
803 &proof.consistency_proof,
804 ) {
805 return Err(WorkflowPreExistenceError::InvalidConsistency);
806 }
807
808 Ok(PreExistenceEvidence {
809 grade: EvidenceGrade::Checked,
810 reason: None,
811 declaration_checkpoint: Some(checkpoint_ref(declaration_checkpoint)),
812 declaration_tree_size: Some(
813 u64::try_from(declaration_checkpoint.tree_size)
814 .expect("checkpoint tree size exceeds u64; please report a bug"),
815 ),
816 first_run_leaf_index: Some(
817 u64::try_from(proof.first_run.inclusion_proof.leaf_index)
818 .expect("leaf index exceeds u64; please report a bug"),
819 ),
820 consistency_to: Some(checkpoint_ref(run_checkpoint)),
821 declaration_signed_at: Some(declaration_checkpoint.signed_at.clone()),
822 first_run_signed_at: Some(run_checkpoint.signed_at.clone()),
823 })
824}
825
826fn require_artifact_id(expected: &str, actual: &str) -> Result<(), WorkflowPreExistenceError> {
827 if expected == actual {
828 Ok(())
829 } else {
830 Err(WorkflowPreExistenceError::ArtifactMismatch {
831 expected: expected.into(),
832 actual: actual.into(),
833 })
834 }
835}
836
837fn verify_checkpoint(
838 which: &str,
839 checkpoint: &Checkpoint,
840 trust: &TrustRootStore,
841) -> Result<(), WorkflowPreExistenceError> {
842 match checkpoint.verify_detailed(trust) {
843 CheckpointVerifyOutcome::Valid => Ok(()),
844 CheckpointVerifyOutcome::SignerNotPinned { .. } => {
845 Err(WorkflowPreExistenceError::CheckpointNotTrusted {
846 which: which.into(),
847 detail: "signer is not pinned under hub_checkpoint".into(),
848 })
849 }
850 CheckpointVerifyOutcome::Invalid { reason } => {
851 Err(WorkflowPreExistenceError::CheckpointNotTrusted {
852 which: which.into(),
853 detail: reason,
854 })
855 }
856 }
857}
858
859fn checkpoint_root_hex<'a>(
860 which: &str,
861 checkpoint: &'a Checkpoint,
862) -> Result<&'a str, WorkflowPreExistenceError> {
863 let Some(root) = checkpoint.root.strip_prefix("sha256:") else {
864 return Err(WorkflowPreExistenceError::InvalidRoot {
865 which: which.into(),
866 });
867 };
868 if root.len() != 64 || hex::decode(root).is_err() {
869 return Err(WorkflowPreExistenceError::InvalidRoot {
870 which: which.into(),
871 });
872 }
873 Ok(root)
874}
875
876fn checkpoint_ref(checkpoint: &Checkpoint) -> String {
877 format!("{}:{}", checkpoint.signer, checkpoint.index)
878}
879
880pub fn evaluate_workflow_conformance(
886 declaration: &WorkflowDeclaration,
887 run: &ObservedWorkflowRun,
888) -> Result<WorkflowConformanceReport, WorkflowConformanceError> {
889 declaration
890 .validate()
891 .map_err(WorkflowConformanceError::InvalidDeclaration)?;
892 validate_run(run)?;
893
894 let nodes: BTreeMap<&str, &WorkflowNode> = declaration
895 .nodes
896 .iter()
897 .map(|node| (node.id.as_str(), node))
898 .collect();
899
900 let mut path_grade = run.attempts[0].grade;
901 for attempt in &run.attempts[1..] {
902 path_grade = path_grade.weakest(attempt.grade);
903 }
904
905 let mut deviations = Vec::new();
906 let mut gaps = Vec::new();
907 let mut asserted_edges = Vec::new();
908
909 if run.attempts[0].node_id != declaration.entry_node {
910 gaps.push(PathGap {
911 node_id: declaration.entry_node.clone(),
912 reason: "missing_entry_node".into(),
913 after: "run_start".into(),
914 evidence: run.attempts[0].evidence.clone(),
915 });
916 }
917
918 for (index, attempt) in run.attempts.iter().enumerate() {
919 if !nodes.contains_key(attempt.node_id.as_str()) {
920 deviations.push(PathDeviation {
921 from: index
922 .checked_sub(1)
923 .map(|previous| run.attempts[previous].node_id.clone())
924 .unwrap_or_else(|| "run_start".into()),
925 to: attempt.node_id.clone(),
926 reason: "undeclared_node".into(),
927 evidence: attempt.evidence.clone(),
928 });
929 }
930 }
931
932 for pair in run.attempts.windows(2) {
933 let from = &pair[0];
934 let to = &pair[1];
935 let evidence = ordered_evidence(&from.evidence, &to.evidence);
936
937 if !nodes.contains_key(from.node_id.as_str()) || !nodes.contains_key(to.node_id.as_str()) {
938 continue;
939 }
940
941 let matching_pair: Vec<&WorkflowEdge> = declaration
942 .edges
943 .iter()
944 .filter(|edge| edge.from == from.node_id && edge.to == to.node_id)
945 .collect();
946 let declared = matching_pair
947 .iter()
948 .any(|edge| condition_holds(edge.when, from.outcome));
949 if !declared {
950 deviations.push(PathDeviation {
951 from: from.node_id.clone(),
952 to: to.node_id.clone(),
953 reason: if matching_pair.is_empty() {
954 "undeclared_edge".into()
955 } else {
956 "edge_condition_not_met".into()
957 },
958 evidence: evidence.clone(),
959 });
960 }
961
962 if from.grade.weakest(to.grade) == EvidenceGrade::Asserted {
963 asserted_edges.push(ObservedEdge {
964 from: from.node_id.clone(),
965 to: to.node_id.clone(),
966 evidence,
967 });
968 }
969 }
970
971 let last = run
972 .attempts
973 .last()
974 .expect("validate_run rejects empty attempts; please report a bug");
975 if run.status == WorkflowRunStatus::Completed
976 && !declaration.terminal_nodes.contains(&last.node_id)
977 {
978 let expected = declaration
979 .edges
980 .iter()
981 .find(|edge| edge.from == last.node_id && condition_holds(edge.when, last.outcome))
982 .map(|edge| edge.to.clone())
983 .unwrap_or_else(|| declaration.terminal_nodes[0].clone());
984 gaps.push(PathGap {
985 node_id: expected,
986 reason: "completed_run_missing_required_terminal".into(),
987 after: last.node_id.clone(),
988 evidence: last.evidence.clone(),
989 });
990 }
991
992 let authority = evaluate_authority(&nodes, &run.attempts, path_grade);
993 let loops = declaration
994 .loops
995 .iter()
996 .map(|workflow_loop| evaluate_loop(workflow_loop, &run.attempts, path_grade))
997 .collect();
998
999 Ok(WorkflowConformanceReport {
1000 run_id: run.run_id.clone(),
1001 workflow_ref: run.workflow_ref.clone(),
1002 pre_existence: PreExistenceReport {
1003 grade: run.pre_existence.grade,
1004 reason: run.pre_existence.reason.clone(),
1005 },
1006 path: PathReport {
1007 grade: path_grade,
1008 deviations,
1009 gaps,
1010 asserted_edges,
1011 },
1012 authority,
1013 loops,
1014 })
1015}
1016
1017fn validate_run(run: &ObservedWorkflowRun) -> Result<(), WorkflowConformanceError> {
1018 if run.run_id.trim().is_empty() {
1019 return Err(WorkflowConformanceError::InvalidRun(
1020 "run_id must not be empty".into(),
1021 ));
1022 }
1023 if run.workflow_ref.trim().is_empty() {
1024 return Err(WorkflowConformanceError::InvalidRun(
1025 "workflow_ref must not be empty".into(),
1026 ));
1027 }
1028 if run.attempts.is_empty() {
1029 return Err(WorkflowConformanceError::InvalidRun(
1030 "at least one observed attempt is required".into(),
1031 ));
1032 }
1033 if let Some((index, _)) = run
1034 .attempts
1035 .iter()
1036 .enumerate()
1037 .find(|(_, attempt)| attempt.evidence.is_empty())
1038 {
1039 return Err(WorkflowConformanceError::InvalidRun(format!(
1040 "attempt {index} has no evidence"
1041 )));
1042 }
1043 for (index, attempt) in run.attempts.iter().enumerate() {
1044 let evidence: BTreeSet<&str> = attempt.evidence.iter().map(String::as_str).collect();
1045 let mut seen_actions = BTreeSet::new();
1046 for action in &attempt.action_evidence {
1047 if action.trim().is_empty() {
1048 return Err(WorkflowConformanceError::InvalidRun(format!(
1049 "attempt {index} has an empty action evidence reference"
1050 )));
1051 }
1052 if !evidence.contains(action.as_str()) {
1053 return Err(WorkflowConformanceError::InvalidRun(format!(
1054 "attempt {index} action evidence `{action}` is not present in its evidence set"
1055 )));
1056 }
1057 if !seen_actions.insert(action.as_str()) {
1058 return Err(WorkflowConformanceError::InvalidRun(format!(
1059 "attempt {index} repeats action evidence `{action}`"
1060 )));
1061 }
1062 }
1063 }
1064 if run.pre_existence.grade == EvidenceGrade::Captured {
1065 return Err(WorkflowConformanceError::InvalidRun(
1066 "pre-existence grade must be checked or asserted; capture alone does not prove log order"
1067 .into(),
1068 ));
1069 }
1070 if run.pre_existence.grade == EvidenceGrade::Checked {
1071 let pre = &run.pre_existence;
1072 let (Some(tree_size), Some(first_leaf)) =
1073 (pre.declaration_tree_size, pre.first_run_leaf_index)
1074 else {
1075 return Err(WorkflowConformanceError::InvalidRun(
1076 "checked pre-existence requires declaration_tree_size and first_run_leaf_index"
1077 .into(),
1078 ));
1079 };
1080 if pre.declaration_checkpoint.is_none() || pre.consistency_to.is_none() {
1081 return Err(WorkflowConformanceError::InvalidRun(
1082 "checked pre-existence requires declaration and consistency checkpoints".into(),
1083 ));
1084 }
1085 if first_leaf < tree_size {
1086 return Err(WorkflowConformanceError::InvalidRun(format!(
1087 "first run leaf index {first_leaf} precedes declaration checkpoint tree size {tree_size}"
1088 )));
1089 }
1090 }
1091 Ok(())
1092}
1093
1094fn evaluate_authority(
1095 nodes: &BTreeMap<&str, &WorkflowNode>,
1096 attempts: &[ObservedNodeAttempt],
1097 grade: EvidenceGrade,
1098) -> WorkflowAuthorityReport {
1099 let mut deviations = Vec::new();
1100 for attempt in attempts {
1101 let Some(node) = nodes.get(attempt.node_id.as_str()) else {
1102 continue;
1103 };
1104 if let Some(expected_actor) = &node.executor.actor {
1105 if &attempt.actor != expected_actor {
1106 deviations.push(AuthorityDeviation {
1107 node_id: attempt.node_id.clone(),
1108 kind: "actor_mismatch".into(),
1109 value: attempt.actor.clone(),
1110 evidence: attempt.evidence.clone(),
1111 });
1112 }
1113 }
1114 if let Some(required_capability) = &node.executor.capability {
1115 if !attempt.capabilities.contains(required_capability) {
1116 deviations.push(AuthorityDeviation {
1117 node_id: attempt.node_id.clone(),
1118 kind: "capability_missing".into(),
1119 value: required_capability.clone(),
1120 evidence: attempt.evidence.clone(),
1121 });
1122 }
1123 }
1124 for tool in &attempt.tools {
1125 if !action_in_scope(tool, &node.allowed_tools) {
1126 deviations.push(AuthorityDeviation {
1127 node_id: attempt.node_id.clone(),
1128 kind: "tool_out_of_scope".into(),
1129 value: tool.clone(),
1130 evidence: attempt
1131 .tool_evidence
1132 .get(tool)
1133 .cloned()
1134 .unwrap_or_else(|| attempt.evidence.clone()),
1135 });
1136 }
1137 }
1138 }
1139 WorkflowAuthorityReport { grade, deviations }
1140}
1141
1142fn evaluate_loop(
1143 workflow_loop: &WorkflowLoop,
1144 attempts: &[ObservedNodeAttempt],
1145 grade: EvidenceGrade,
1146) -> LoopReport {
1147 let traversals: Vec<usize> = attempts
1148 .windows(2)
1149 .enumerate()
1150 .filter_map(|(index, pair)| {
1151 (pair[0].node_id == workflow_loop.back_edge.from
1152 && pair[1].node_id == workflow_loop.back_edge.to)
1153 .then_some(index + 1)
1154 })
1155 .collect();
1156 let iterations =
1157 u32::try_from(traversals.len()).expect("attempt count exceeds u32; please report a bug");
1158
1159 let loop_actions = traversals
1164 .first()
1165 .map(|first_retry| {
1166 attempts[*first_retry..]
1167 .iter()
1168 .map(|attempt| attempt.action_evidence.len() as u64)
1169 .sum::<u64>()
1170 })
1171 .unwrap_or(0);
1172 let budget_exceeded = workflow_loop
1173 .budget
1174 .as_ref()
1175 .and_then(|budget| budget.max_actions)
1176 .is_some_and(|max| loop_actions > u64::from(max));
1177
1178 LoopReport {
1179 id: workflow_loop.id.clone(),
1180 grade,
1181 iterations,
1182 max_iterations: workflow_loop.max_iterations,
1183 limit_exceeded: iterations > workflow_loop.max_iterations,
1184 budget_exceeded,
1185 }
1186}
1187
1188fn edge_closes_cycle(
1189 back_edge: &EdgeRef,
1190 node_ids: &BTreeSet<String>,
1191 edges: &[WorkflowEdge],
1192 bounded_back_edges: &BTreeSet<EdgeRef>,
1193) -> bool {
1194 if !node_ids.contains(&back_edge.from) || !node_ids.contains(&back_edge.to) {
1195 return false;
1196 }
1197 let mut pending = vec![back_edge.to.as_str()];
1198 let mut visited = BTreeSet::new();
1199 while let Some(node) = pending.pop() {
1200 if node == back_edge.from {
1201 return true;
1202 }
1203 if !visited.insert(node) {
1204 continue;
1205 }
1206 for edge in edges.iter().filter(|edge| edge.from == node) {
1207 let edge_ref = EdgeRef {
1208 from: edge.from.clone(),
1209 to: edge.to.clone(),
1210 };
1211 if !bounded_back_edges.contains(&edge_ref) {
1212 pending.push(edge.to.as_str());
1213 }
1214 }
1215 }
1216 false
1217}
1218
1219fn contains_unbounded_cycle(
1220 node_ids: &BTreeSet<String>,
1221 edges: &[WorkflowEdge],
1222 bounded_back_edges: &BTreeSet<EdgeRef>,
1223) -> bool {
1224 let mut indegree: BTreeMap<&str, usize> =
1225 node_ids.iter().map(|node| (node.as_str(), 0)).collect();
1226 let remaining_edges: Vec<&WorkflowEdge> = edges
1227 .iter()
1228 .filter(|edge| node_ids.contains(&edge.from) && node_ids.contains(&edge.to))
1229 .filter(|edge| {
1230 !bounded_back_edges.contains(&EdgeRef {
1231 from: edge.from.clone(),
1232 to: edge.to.clone(),
1233 })
1234 })
1235 .collect();
1236
1237 for edge in &remaining_edges {
1238 if let Some(count) = indegree.get_mut(edge.to.as_str()) {
1239 *count += 1;
1240 }
1241 }
1242 let mut ready: Vec<&str> = indegree
1243 .iter()
1244 .filter_map(|(node, count)| (*count == 0).then_some(*node))
1245 .collect();
1246 let mut visited = 0usize;
1247 while let Some(node) = ready.pop() {
1248 visited += 1;
1249 for edge in remaining_edges.iter().filter(|edge| edge.from == node) {
1250 let count = indegree
1251 .get_mut(edge.to.as_str())
1252 .expect("validated edges name declared nodes; please report a bug");
1253 *count -= 1;
1254 if *count == 0 {
1255 ready.push(edge.to.as_str());
1256 }
1257 }
1258 }
1259 visited != node_ids.len()
1260}
1261
1262fn condition_holds(condition: EdgeCondition, outcome: NodeOutcome) -> bool {
1263 match condition {
1264 EdgeCondition::Always => true,
1265 EdgeCondition::OnPass => outcome == NodeOutcome::Pass,
1266 EdgeCondition::OnFail => outcome == NodeOutcome::Fail,
1267 EdgeCondition::OnRefused => outcome == NodeOutcome::Refused,
1268 }
1269}
1270
1271fn ordered_evidence(first: &[String], second: &[String]) -> Vec<String> {
1272 let mut seen = BTreeSet::new();
1273 first
1274 .iter()
1275 .chain(second)
1276 .filter(|item| seen.insert((*item).clone()))
1277 .cloned()
1278 .collect()
1279}
1280
1281fn invalid(errors: &mut Vec<WorkflowValidationError>, field: &str, detail: &str) {
1282 errors.push(WorkflowValidationError {
1283 field: field.into(),
1284 detail: detail.into(),
1285 });
1286}
1287
1288#[derive(Debug, Clone, PartialEq, Eq)]
1293pub enum WorkflowRunVerifyError {
1294 DeclarationSignature(String),
1296 DeclarationWrongPayloadType { actual: String },
1298 DeclarationMalformed(String),
1300 DeclarationNotAWorkflow { actual: String },
1302 DeclarationMissingPayload,
1304 InvalidDeclaration(Vec<WorkflowValidationError>),
1306 WorkflowRefMismatch { declaration: String, run: String },
1308 RunBinding(WorkflowRunBindingError),
1310 PreExistence(WorkflowPreExistenceError),
1312 Conformance(WorkflowConformanceError),
1315}
1316
1317impl fmt::Display for WorkflowRunVerifyError {
1318 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1319 match self {
1320 WorkflowRunVerifyError::DeclarationSignature(detail) => {
1321 write!(f, "workflow declaration did not verify: {detail}")
1322 }
1323 WorkflowRunVerifyError::DeclarationWrongPayloadType { actual } => write!(
1324 f,
1325 "workflow declaration has payload type `{actual}`, expected a receipt"
1326 ),
1327 WorkflowRunVerifyError::DeclarationMalformed(detail) => {
1328 write!(f, "workflow declaration statement is unreadable: {detail}")
1329 }
1330 WorkflowRunVerifyError::DeclarationNotAWorkflow { actual } => write!(
1331 f,
1332 "signed receipt is `{actual}`, not a workflow.v1 declaration"
1333 ),
1334 WorkflowRunVerifyError::DeclarationMissingPayload => {
1335 write!(f, "workflow.v1 receipt carries no declaration payload")
1336 }
1337 WorkflowRunVerifyError::InvalidDeclaration(errors) => {
1338 let detail = errors
1339 .iter()
1340 .map(ToString::to_string)
1341 .collect::<Vec<_>>()
1342 .join("; ");
1343 write!(f, "workflow declaration is invalid: {detail}")
1344 }
1345 WorkflowRunVerifyError::WorkflowRefMismatch { declaration, run } => write!(
1346 f,
1347 "observation set names workflow `{run}`, but the signed declaration is `{declaration}`"
1348 ),
1349 WorkflowRunVerifyError::RunBinding(error) => {
1350 write!(f, "first-run binding failed: {error}")
1351 }
1352 WorkflowRunVerifyError::PreExistence(error) => {
1353 write!(f, "pre-existence proof failed: {error}")
1354 }
1355 WorkflowRunVerifyError::Conformance(error) => write!(f, "{error}"),
1356 }
1357 }
1358}
1359
1360impl std::error::Error for WorkflowRunVerifyError {}
1361
1362pub fn verify_workflow_run(
1387 declaration_envelope: &Envelope,
1388 first_run_envelope: &Envelope,
1389 pre_existence_proof: Option<&WorkflowPreExistenceProof>,
1390 observed: &ObservedWorkflowRun,
1391 verifier: &Verifier,
1392 trust: &TrustRootStore,
1393) -> Result<WorkflowConformanceReport, WorkflowRunVerifyError> {
1394 let verified_declaration = verifier
1395 .verify(declaration_envelope)
1396 .map_err(|e| WorkflowRunVerifyError::DeclarationSignature(e.to_string()))?;
1397
1398 let expected_receipt = payload_type("receipt");
1399 if verified_declaration.payload_type != expected_receipt {
1400 return Err(WorkflowRunVerifyError::DeclarationWrongPayloadType {
1401 actual: verified_declaration.payload_type,
1402 });
1403 }
1404
1405 let receipt: ReceiptStatement = declaration_envelope
1406 .unmarshal_statement()
1407 .map_err(|e| WorkflowRunVerifyError::DeclarationMalformed(e.to_string()))?;
1408 if receipt.kind != "workflow.v1" {
1409 return Err(WorkflowRunVerifyError::DeclarationNotAWorkflow {
1410 actual: receipt.kind,
1411 });
1412 }
1413 let payload = receipt
1414 .payload
1415 .ok_or(WorkflowRunVerifyError::DeclarationMissingPayload)?;
1416 let declaration: WorkflowDeclaration = serde_json::from_value(payload)
1417 .map_err(|e| WorkflowRunVerifyError::DeclarationMalformed(e.to_string()))?;
1418 declaration
1419 .validate()
1420 .map_err(WorkflowRunVerifyError::InvalidDeclaration)?;
1421
1422 let workflow_ref = verified_declaration.artifact_id;
1425 if observed.workflow_ref != workflow_ref {
1426 return Err(WorkflowRunVerifyError::WorkflowRefMismatch {
1427 declaration: workflow_ref,
1428 run: observed.workflow_ref.clone(),
1429 });
1430 }
1431
1432 let first_run_artifact = verifier
1435 .verify(first_run_envelope)
1436 .map_err(|e| {
1437 WorkflowRunVerifyError::RunBinding(WorkflowRunBindingError::Signature(e.to_string()))
1438 })?
1439 .artifact_id;
1440 verify_first_run_workflow_binding(
1441 &workflow_ref,
1442 &first_run_artifact,
1443 first_run_envelope,
1444 verifier,
1445 )
1446 .map_err(WorkflowRunVerifyError::RunBinding)?;
1447
1448 let pre_existence = match pre_existence_proof {
1449 Some(proof) => {
1450 verify_workflow_pre_existence(proof, &workflow_ref, &first_run_artifact, trust)
1451 .map_err(WorkflowRunVerifyError::PreExistence)?
1452 }
1453 None => PreExistenceEvidence {
1454 grade: EvidenceGrade::Asserted,
1455 reason: Some("no checkpoint proof supplied; declaration ordering is unproven".into()),
1456 declaration_checkpoint: None,
1457 declaration_tree_size: None,
1458 first_run_leaf_index: None,
1459 consistency_to: None,
1460 declaration_signed_at: None,
1461 first_run_signed_at: None,
1462 },
1463 };
1464
1465 let mut run = observed.clone();
1466 run.pre_existence = pre_existence;
1467
1468 evaluate_workflow_conformance(&declaration, &run).map_err(WorkflowRunVerifyError::Conformance)
1469}
1470
1471#[derive(Debug, Clone, PartialEq, Eq)]
1481pub struct VerifiedAction {
1482 pub artifact_id: String,
1485 pub actor: String,
1486 pub tool: String,
1488 pub capabilities: Vec<String>,
1490 pub outcome: NodeOutcome,
1491 pub node_label: Option<String>,
1496}
1497
1498#[derive(Debug, Clone, PartialEq, Eq)]
1502pub enum AttributionIssue {
1503 Ambiguous {
1505 artifact_id: String,
1506 candidates: Vec<String>,
1507 },
1508 LabelNotAdmissible {
1510 artifact_id: String,
1511 label: String,
1512 candidates: Vec<String>,
1513 },
1514 UndeclaredExecutor { artifact_id: String, actor: String },
1516}
1517
1518impl AttributionIssue {
1519 pub fn artifact_id(&self) -> &str {
1520 match self {
1521 Self::Ambiguous { artifact_id, .. }
1522 | Self::LabelNotAdmissible { artifact_id, .. }
1523 | Self::UndeclaredExecutor { artifact_id, .. } => artifact_id,
1524 }
1525 }
1526}
1527
1528impl fmt::Display for AttributionIssue {
1529 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1530 match self {
1531 Self::Ambiguous {
1532 artifact_id,
1533 candidates,
1534 } => write!(
1535 f,
1536 "{artifact_id} is admitted by {} nodes ({}) and no label picks among them",
1537 candidates.len(),
1538 candidates.join(", ")
1539 ),
1540 Self::LabelNotAdmissible {
1541 artifact_id,
1542 label,
1543 candidates,
1544 } => write!(
1545 f,
1546 "{artifact_id} is labelled `{label}`, which does not admit it; admissible nodes are {}",
1547 candidates.join(", ")
1548 ),
1549 Self::UndeclaredExecutor { artifact_id, actor } => write!(
1550 f,
1551 "{artifact_id} was signed by `{actor}`, which no declared node's executor admits"
1552 ),
1553 }
1554 }
1555}
1556
1557#[derive(Debug, Clone, PartialEq, Eq)]
1558pub enum DerivationError {
1559 NoActions,
1562 EmptyArtifactId {
1563 index: usize,
1564 },
1565 DuplicateAction {
1569 artifact_id: String,
1570 },
1571 NoAttributableActions {
1573 issues: Vec<AttributionIssue>,
1574 },
1575}
1576
1577impl fmt::Display for DerivationError {
1578 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1579 match self {
1580 Self::NoActions => write!(
1581 f,
1582 "no verified actions: an observation set cannot be derived from nothing"
1583 ),
1584 Self::EmptyArtifactId { index } => {
1585 write!(f, "verified action {index} has an empty artifact id")
1586 }
1587 Self::DuplicateAction { artifact_id } => write!(
1588 f,
1589 "verified action `{artifact_id}` appears more than once; duplicate action references fail closed"
1590 ),
1591 Self::NoAttributableActions { issues } => {
1592 let detail = issues
1593 .iter()
1594 .map(ToString::to_string)
1595 .collect::<Vec<_>>()
1596 .join("; ");
1597 write!(
1598 f,
1599 "no action could be attributed to a declared node: {detail}"
1600 )
1601 }
1602 }
1603 }
1604}
1605
1606impl std::error::Error for DerivationError {}
1607
1608#[derive(Debug, Clone, PartialEq, Eq)]
1610pub struct DerivedObservation {
1611 pub run: ObservedWorkflowRun,
1612 pub issues: Vec<AttributionIssue>,
1615}
1616
1617pub fn derive_observed_run(
1637 declaration: &WorkflowDeclaration,
1638 run_id: &str,
1639 workflow_ref: &str,
1640 status: WorkflowRunStatus,
1641 actions: &[VerifiedAction],
1642) -> Result<DerivedObservation, DerivationError> {
1643 if actions.is_empty() {
1644 return Err(DerivationError::NoActions);
1645 }
1646 let mut seen: BTreeSet<&str> = BTreeSet::new();
1647 for (index, action) in actions.iter().enumerate() {
1648 if action.artifact_id.trim().is_empty() {
1649 return Err(DerivationError::EmptyArtifactId { index });
1650 }
1651 if !seen.insert(action.artifact_id.as_str()) {
1652 return Err(DerivationError::DuplicateAction {
1653 artifact_id: action.artifact_id.clone(),
1654 });
1655 }
1656 }
1657
1658 let mut attempts: Vec<ObservedNodeAttempt> = Vec::new();
1659 let mut issues = Vec::new();
1660 let mut iterations: BTreeMap<String, u32> = BTreeMap::new();
1661
1662 for action in actions {
1663 let current_node = attempts.last().map(|attempt| attempt.node_id.clone());
1664 let (node_id, grade) = match attribute_action(declaration, action, current_node.as_deref())
1665 {
1666 Ok(attributed) => attributed,
1667 Err(issue) => {
1668 issues.push(issue);
1669 continue;
1670 }
1671 };
1672
1673 let extends_current = attempts
1674 .last()
1675 .is_some_and(|attempt| attempt.node_id == node_id);
1676 if !extends_current {
1677 let iteration = iterations
1678 .entry(node_id.clone())
1679 .and_modify(|seen| *seen += 1)
1680 .or_insert(0);
1681 attempts.push(ObservedNodeAttempt {
1682 node_id,
1683 iteration: *iteration,
1684 actor: action.actor.clone(),
1685 capabilities: action.capabilities.clone(),
1686 tools: Vec::new(),
1687 outcome: action.outcome,
1688 grade,
1689 evidence: Vec::new(),
1690 action_evidence: Vec::new(),
1691 tool_evidence: BTreeMap::new(),
1692 });
1693 }
1694
1695 let attempt = attempts
1696 .last_mut()
1697 .expect("an attempt was just pushed or extended");
1698 if !attempt.tools.contains(&action.tool) {
1699 attempt.tools.push(action.tool.clone());
1700 }
1701 attempt.evidence.push(action.artifact_id.clone());
1702 attempt.action_evidence.push(action.artifact_id.clone());
1703 attempt
1704 .tool_evidence
1705 .entry(action.tool.clone())
1706 .or_default()
1707 .push(action.artifact_id.clone());
1708 for capability in &action.capabilities {
1709 if !attempt.capabilities.contains(capability) {
1710 attempt.capabilities.push(capability.clone());
1711 }
1712 }
1713 attempt.outcome = action.outcome;
1716 attempt.grade = attempt.grade.weakest(grade);
1717 }
1718
1719 if attempts.is_empty() {
1720 return Err(DerivationError::NoAttributableActions { issues });
1721 }
1722
1723 for attempt in &mut attempts {
1724 attempt.tools.sort();
1725 }
1726
1727 Ok(DerivedObservation {
1728 run: ObservedWorkflowRun {
1729 run_id: run_id.to_string(),
1730 status,
1731 workflow_ref: workflow_ref.to_string(),
1732 pre_existence: PreExistenceEvidence {
1733 grade: EvidenceGrade::Asserted,
1734 reason: Some("derived observation set carries no checkpoint evidence".into()),
1735 declaration_checkpoint: None,
1736 declaration_tree_size: None,
1737 first_run_leaf_index: None,
1738 consistency_to: None,
1739 declaration_signed_at: None,
1740 first_run_signed_at: None,
1741 },
1742 attempts,
1743 },
1744 issues,
1745 })
1746}
1747
1748fn attribute_action(
1751 declaration: &WorkflowDeclaration,
1752 action: &VerifiedAction,
1753 current_node: Option<&str>,
1754) -> Result<(String, EvidenceGrade), AttributionIssue> {
1755 let executor_candidates: Vec<&WorkflowNode> = declaration
1756 .nodes
1757 .iter()
1758 .filter(|node| executor_admits(&node.executor, action))
1759 .collect();
1760 if executor_candidates.is_empty() {
1761 return Err(AttributionIssue::UndeclaredExecutor {
1762 artifact_id: action.artifact_id.clone(),
1763 actor: action.actor.clone(),
1764 });
1765 }
1766
1767 let admitting: Vec<&WorkflowNode> = executor_candidates
1768 .iter()
1769 .copied()
1770 .filter(|node| action_in_scope(&action.tool, &node.allowed_tools))
1771 .collect();
1772
1773 match admitting.len() {
1774 1 => Ok((admitting[0].id.clone(), EvidenceGrade::Checked)),
1776 0 => {
1777 if let Some(current) = current_node {
1781 if executor_candidates.iter().any(|node| node.id == current) {
1782 return Ok((current.to_string(), EvidenceGrade::Captured));
1783 }
1784 }
1785 if executor_candidates.len() == 1 {
1786 return Ok((executor_candidates[0].id.clone(), EvidenceGrade::Captured));
1787 }
1788 label_tiebreak(action, &executor_candidates)
1789 }
1790 _ => label_tiebreak(action, &admitting),
1791 }
1792}
1793
1794fn executor_admits(executor: &ExecutorConstraint, action: &VerifiedAction) -> bool {
1795 match (&executor.actor, &executor.capability) {
1796 (Some(actor), _) => actor == &action.actor,
1797 (None, Some(capability)) => action.capabilities.contains(capability),
1798 (None, None) => false,
1800 }
1801}
1802
1803fn label_tiebreak(
1806 action: &VerifiedAction,
1807 candidates: &[&WorkflowNode],
1808) -> Result<(String, EvidenceGrade), AttributionIssue> {
1809 let names: Vec<String> = candidates.iter().map(|node| node.id.clone()).collect();
1810 let Some(label) = &action.node_label else {
1811 return Err(AttributionIssue::Ambiguous {
1812 artifact_id: action.artifact_id.clone(),
1813 candidates: names,
1814 });
1815 };
1816 if names.iter().any(|name| name == label) {
1817 Ok((label.clone(), EvidenceGrade::Captured))
1820 } else {
1821 Err(AttributionIssue::LabelNotAdmissible {
1822 artifact_id: action.artifact_id.clone(),
1823 label: label.clone(),
1824 candidates: names,
1825 })
1826 }
1827}
1828
1829#[cfg(test)]
1830mod tests {
1831 use super::*;
1832 use crate::attestation::{sign, Ed25519Signer, Signer};
1833 use crate::merkle::ArtifactSummary;
1834 use crate::trust::{encode_ed25519_pubkey, TrustRoot, TrustRootKind};
1835
1836 fn valid_declaration() -> WorkflowDeclaration {
1837 serde_json::from_str(include_str!(
1838 "../../tests/fixtures/workflow-conformance/declaration.json"
1839 ))
1840 .expect("golden declaration parses")
1841 }
1842
1843 fn real_pre_existence_proof() -> (WorkflowPreExistenceProof, TrustRootStore) {
1844 pre_existence_proof_for("art_workflow", "art_first_run")
1845 }
1846
1847 fn pre_existence_proof_for(
1851 declaration_id: &str,
1852 first_run_id: &str,
1853 ) -> (WorkflowPreExistenceProof, TrustRootStore) {
1854 use ed25519_dalek::VerifyingKey;
1855
1856 let signer = Ed25519Signer::generate("key_workflow_checkpoint")
1857 .expect("test signer generation succeeds");
1858 let public_key: [u8; 32] = signer
1859 .public_key_bytes()
1860 .try_into()
1861 .expect("Ed25519 public key is 32 bytes");
1862 let verifying_key =
1863 VerifyingKey::from_bytes(&public_key).expect("test public key is valid");
1864 let trust = TrustRootStore::with_roots(vec![TrustRoot {
1865 key_id: signer.key_id().into(),
1866 public_key: encode_ed25519_pubkey(&verifying_key),
1867 kind: TrustRootKind::HubCheckpoint,
1868 label: "workflow test checkpoint".into(),
1869 added_at: "2026-08-17T00:00:00Z".into(),
1870 }]);
1871
1872 let mut tree = MerkleTree::new();
1873 tree.append(declaration_id);
1874 let declaration_inclusion = tree
1875 .inclusion_proof(0)
1876 .expect("declaration inclusion proof exists");
1877 let declaration_checkpoint =
1878 Checkpoint::create(10, &tree, &signer).expect("declaration checkpoint signs");
1879
1880 tree.append(first_run_id);
1881 let first_run_inclusion = tree
1882 .inclusion_proof(1)
1883 .expect("first-run inclusion proof exists");
1884 let first_run_checkpoint =
1885 Checkpoint::create(11, &tree, &signer).expect("run checkpoint signs");
1886 let consistency_proof = tree
1887 .consistency_proof(declaration_checkpoint.tree_size)
1888 .expect("consistency proof exists");
1889
1890 let summary = |action: &str| ArtifactSummary {
1891 actor: "agent://claude-code".into(),
1892 action: action.into(),
1893 timestamp: "2026-08-17T00:00:00Z".into(),
1894 key_id: "key_agent".into(),
1895 };
1896 (
1897 WorkflowPreExistenceProof {
1898 declaration: ProofFile {
1899 artifact_id: declaration_id.into(),
1900 artifact_summary: summary("workflow.declare"),
1901 inclusion_proof: declaration_inclusion,
1902 checkpoint: declaration_checkpoint,
1903 },
1904 first_run: ProofFile {
1905 artifact_id: first_run_id.into(),
1906 artifact_summary: summary("workflow.start"),
1907 inclusion_proof: first_run_inclusion,
1908 checkpoint: first_run_checkpoint,
1909 },
1910 consistency_proof,
1911 },
1912 trust,
1913 )
1914 }
1915
1916 fn signed_workflow_bound_run(workflow_ref: &str) -> (String, Envelope, Verifier) {
1917 let signer =
1918 Ed25519Signer::generate("key_workflow_run").expect("test signer generation succeeds");
1919 let mut action = ActionStatement::new("agent://claude-code", "session.start");
1920 action.meta = Some(serde_json::json!({
1921 "session_start": true,
1922 "workflow_ref": workflow_ref,
1923 }));
1924 let result =
1925 sign(&payload_type("action"), &action, &signer).expect("session start action signs");
1926 let verifier = Verifier::from_signer(&signer);
1927 (result.artifact_id, result.envelope, verifier)
1928 }
1929
1930 #[test]
1931 fn signed_session_start_binds_first_run_to_workflow() {
1932 let workflow_ref = "art_0123456789abcdef0123456789abcdef";
1933 let (first_run_id, envelope, verifier) = signed_workflow_bound_run(workflow_ref);
1934
1935 verify_first_run_workflow_binding(workflow_ref, &first_run_id, &envelope, &verifier)
1936 .expect("trusted session start binds the workflow inside signed bytes");
1937 }
1938
1939 #[test]
1940 fn first_run_binding_rejects_substitution_and_untrusted_signer() {
1941 let workflow_ref = "art_0123456789abcdef0123456789abcdef";
1942 let (first_run_id, envelope, verifier) = signed_workflow_bound_run(workflow_ref);
1943
1944 let substituted = verify_first_run_workflow_binding(
1945 "art_ffffffffffffffffffffffffffffffff",
1946 &first_run_id,
1947 &envelope,
1948 &verifier,
1949 )
1950 .expect_err("a run bound to one workflow cannot be relabeled as another");
1951 assert_eq!(
1952 substituted,
1953 WorkflowRunBindingError::WorkflowMismatch {
1954 expected: "art_ffffffffffffffffffffffffffffffff".into(),
1955 actual: workflow_ref.into(),
1956 }
1957 );
1958
1959 let untrusted = verify_first_run_workflow_binding(
1960 workflow_ref,
1961 &first_run_id,
1962 &envelope,
1963 &Verifier::new(std::collections::HashMap::new()),
1964 )
1965 .expect_err("a key id in the envelope is not a verifier trust decision");
1966 assert!(matches!(untrusted, WorkflowRunBindingError::Signature(_)));
1967 }
1968
1969 #[test]
1970 fn mutating_signed_first_run_workflow_ref_invalidates_binding() {
1971 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
1972
1973 let workflow_ref = "art_0123456789abcdef0123456789abcdef";
1974 let (first_run_id, mut envelope, verifier) = signed_workflow_bound_run(workflow_ref);
1975 let mut payload: serde_json::Value = envelope
1976 .unmarshal_statement()
1977 .expect("signed action payload is JSON");
1978 payload["meta"]["workflow_ref"] =
1979 serde_json::Value::String("art_ffffffffffffffffffffffffffffffff".into());
1980 envelope.payload = URL_SAFE_NO_PAD
1981 .encode(serde_json::to_vec(&payload).expect("test mutation serializes to JSON bytes"));
1982
1983 let error =
1984 verify_first_run_workflow_binding(workflow_ref, &first_run_id, &envelope, &verifier)
1985 .expect_err("workflow_ref is inside signed PAE bytes and cannot be wire-edited");
1986 assert!(matches!(error, WorkflowRunBindingError::Signature(_)));
1987 }
1988
1989 #[test]
1990 fn real_checkpoints_prove_declaration_pre_existed_first_run() {
1991 let (proof, trust) = real_pre_existence_proof();
1992 let evidence =
1993 verify_workflow_pre_existence(&proof, "art_workflow", "art_first_run", &trust)
1994 .expect("real inclusion, ordering, and consistency evidence passes");
1995
1996 assert_eq!(evidence.grade, EvidenceGrade::Checked);
1997 assert_eq!(evidence.declaration_tree_size, Some(1));
1998 assert_eq!(evidence.first_run_leaf_index, Some(1));
1999 }
2000
2001 #[test]
2002 fn pre_existence_rejects_checkpoints_from_different_trusted_logs() {
2003 let (mut proof, _) = real_pre_existence_proof();
2004 let second_signer = Ed25519Signer::generate("key_other_checkpoint")
2005 .expect("second checkpoint signer generation succeeds");
2006 let mut tree = MerkleTree::new();
2007 tree.append("art_workflow");
2008 tree.append("art_first_run");
2009 proof.first_run.checkpoint =
2010 Checkpoint::create(11, &tree, &second_signer).expect("second log checkpoint signs");
2011
2012 let trust = TrustRootStore::with_roots(vec![
2013 TrustRoot {
2014 key_id: proof.declaration.checkpoint.signer.clone(),
2015 public_key: proof.declaration.checkpoint.public_key.clone(),
2016 kind: TrustRootKind::HubCheckpoint,
2017 label: "declaration log".into(),
2018 added_at: "2026-08-17T00:00:00Z".into(),
2019 },
2020 TrustRoot {
2021 key_id: proof.first_run.checkpoint.signer.clone(),
2022 public_key: proof.first_run.checkpoint.public_key.clone(),
2023 kind: TrustRootKind::HubCheckpoint,
2024 label: "unrelated run log".into(),
2025 added_at: "2026-08-17T00:00:00Z".into(),
2026 },
2027 ]);
2028
2029 let error = verify_workflow_pre_existence(&proof, "art_workflow", "art_first_run", &trust)
2030 .expect_err("two trusted checkpoint keys do not establish one append-only log");
2031 assert_eq!(
2032 error,
2033 WorkflowPreExistenceError::LogIdentityMismatch {
2034 declaration_signer: proof.declaration.checkpoint.signer.clone(),
2035 first_run_signer: proof.first_run.checkpoint.signer.clone(),
2036 }
2037 );
2038 }
2039
2040 #[test]
2041 fn pre_existence_fails_on_untrusted_or_inconsistent_evidence() {
2042 let (mut proof, trust) = real_pre_existence_proof();
2043 let untrusted = verify_workflow_pre_existence(
2044 &proof,
2045 "art_workflow",
2046 "art_first_run",
2047 &TrustRootStore::empty(),
2048 )
2049 .expect_err("self-signed unpinned checkpoints cannot prove ordering");
2050 assert!(matches!(
2051 untrusted,
2052 WorkflowPreExistenceError::CheckpointNotTrusted { .. }
2053 ));
2054
2055 proof.consistency_proof[0].replace_range(0..2, "ff");
2056 let inconsistent =
2057 verify_workflow_pre_existence(&proof, "art_workflow", "art_first_run", &trust)
2058 .expect_err("tampered consistency evidence must fail");
2059 assert_eq!(inconsistent, WorkflowPreExistenceError::InvalidConsistency);
2060 }
2061
2062 #[test]
2063 fn pre_existence_rejects_a_leaf_index_relabel() {
2064 let (mut proof, trust) = real_pre_existence_proof();
2065 proof.first_run.inclusion_proof.leaf_index = 0;
2066
2067 let error = verify_workflow_pre_existence(&proof, "art_workflow", "art_first_run", &trust)
2068 .expect_err("a valid hash path cannot be relabeled to another leaf position");
2069 assert_eq!(
2070 error,
2071 WorkflowPreExistenceError::InvalidInclusion {
2072 which: "first run".into()
2073 }
2074 );
2075 }
2076
2077 #[test]
2078 fn pre_existence_binds_both_expected_artifact_ids() {
2079 let (proof, trust) = real_pre_existence_proof();
2080 let error = verify_workflow_pre_existence(
2081 &proof,
2082 "art_different_workflow",
2083 "art_first_run",
2084 &trust,
2085 )
2086 .expect_err("a proof for another workflow cannot be substituted");
2087 assert!(matches!(
2088 error,
2089 WorkflowPreExistenceError::ArtifactMismatch { .. }
2090 ));
2091 }
2092
2093 #[test]
2094 fn declaration_rejects_unknown_control_fields() {
2095 let mut value: serde_json::Value = serde_json::from_str(include_str!(
2096 "../../tests/fixtures/workflow-conformance/declaration.json"
2097 ))
2098 .expect("golden declaration parses as JSON");
2099 value["nodes"][0]["retry_policy"] = serde_json::json!({ "max": 99 });
2100
2101 let error = serde_json::from_value::<WorkflowDeclaration>(value)
2102 .expect_err("an unchecked control field must not be silently ignored");
2103 assert!(error.to_string().contains("unknown field"));
2104 }
2105
2106 #[test]
2107 fn declaration_rejects_vacuous_and_dangling_shapes() {
2108 let mut declaration = valid_declaration();
2109 declaration.nodes[0].executor.capability = Some("second.constraint".into());
2110 declaration.loops[0].max_iterations = 0;
2111 declaration.edges[0].to = "missing".into();
2112
2113 let errors = declaration
2114 .validate()
2115 .expect_err("invalid shape is refused");
2116 assert!(errors.iter().any(|e| e.field == "nodes[0].executor"));
2117 assert!(errors.iter().any(|e| e.field == "edges[0].to"));
2118 assert!(errors.iter().any(|e| e.field == "loops[0].max_iterations"));
2119 }
2120
2121 #[test]
2122 fn undeclared_cycles_are_refused_instead_of_becoming_unbounded_loops() {
2123 let mut declaration = valid_declaration();
2124 declaration.edges.push(WorkflowEdge {
2125 from: "finish".into(),
2126 to: "inspect".into(),
2127 when: EdgeCondition::Always,
2128 });
2129
2130 let errors = declaration
2131 .validate()
2132 .expect_err("a cycle without a bounded loop declaration is refused");
2133 assert!(errors
2134 .iter()
2135 .any(|error| { error.field == "edges" && error.detail.contains("bounded loop") }));
2136 }
2137
2138 #[test]
2139 fn a_loop_marker_must_name_an_edge_that_actually_closes_a_cycle() {
2140 let mut declaration = valid_declaration();
2141 declaration.loops[0].back_edge = EdgeRef {
2142 from: "inspect".into(),
2143 to: "change".into(),
2144 };
2145
2146 let errors = declaration
2147 .validate()
2148 .expect_err("a forward edge cannot masquerade as a bounded back edge");
2149 assert!(errors.iter().any(|error| {
2150 error.field == "loops[0].back_edge" && error.detail.contains("close a path")
2151 }));
2152 }
2153
2154 #[test]
2155 fn checked_pre_existence_requires_ordering_basis() {
2156 let run = ObservedWorkflowRun {
2157 run_id: "run".into(),
2158 status: WorkflowRunStatus::Completed,
2159 workflow_ref: "art_workflow".into(),
2160 pre_existence: PreExistenceEvidence {
2161 grade: EvidenceGrade::Checked,
2162 reason: None,
2163 declaration_checkpoint: None,
2164 declaration_tree_size: None,
2165 first_run_leaf_index: None,
2166 consistency_to: None,
2167 declaration_signed_at: None,
2168 first_run_signed_at: None,
2169 },
2170 attempts: vec![ObservedNodeAttempt {
2171 node_id: "inspect".into(),
2172 iteration: 0,
2173 actor: "agent://claude-code".into(),
2174 capabilities: vec![],
2175 tools: vec!["Read".into()],
2176 outcome: NodeOutcome::Completed,
2177 grade: EvidenceGrade::Checked,
2178 evidence: vec!["art_read".into()],
2179 action_evidence: vec!["art_read".into()],
2180 tool_evidence: BTreeMap::new(),
2181 }],
2182 };
2183
2184 let error = evaluate_workflow_conformance(&valid_declaration(), &run)
2185 .expect_err("checked without checkpoint evidence is refused");
2186 assert!(matches!(error, WorkflowConformanceError::InvalidRun(_)));
2187
2188 let mut captured = run;
2189 captured.pre_existence.grade = EvidenceGrade::Captured;
2190 let error = evaluate_workflow_conformance(&valid_declaration(), &captured)
2191 .expect_err("capture alone cannot prove declaration ordering");
2192 assert!(matches!(error, WorkflowConformanceError::InvalidRun(_)));
2193 }
2194
2195 #[test]
2196 fn a_single_undeclared_attempt_is_a_path_deviation() {
2197 let fixture: serde_json::Value = serde_json::from_str(include_str!(
2198 "../../tests/fixtures/workflow-conformance/valid.json"
2199 ))
2200 .expect("valid fixture parses as JSON");
2201 let mut run: ObservedWorkflowRun =
2202 serde_json::from_value(fixture["run"].clone()).expect("valid fixture run parses");
2203 run.status = WorkflowRunStatus::Incomplete;
2204 run.attempts.truncate(1);
2205 run.attempts[0].node_id = "undeclared".into();
2206
2207 let report = evaluate_workflow_conformance(&valid_declaration(), &run)
2208 .expect("undeclared evidence produces a report rather than a parser error");
2209 assert_eq!(
2210 report.path.deviations,
2211 vec![PathDeviation {
2212 from: "run_start".into(),
2213 to: "undeclared".into(),
2214 reason: "undeclared_node".into(),
2215 evidence: vec!["art_inspect".into()],
2216 }]
2217 );
2218 }
2219
2220 #[test]
2221 fn loop_action_budget_counts_signed_actions_not_tool_labels() {
2222 let fixture: serde_json::Value = serde_json::from_str(include_str!(
2223 "../../tests/fixtures/workflow-conformance/loop-cap.json"
2224 ))
2225 .expect("loop fixture parses as JSON");
2226 let mut run: ObservedWorkflowRun =
2227 serde_json::from_value(fixture["run"].clone()).expect("loop fixture run parses");
2228 for attempt in &mut run.attempts[3..] {
2229 attempt.tools.clear();
2230 attempt.action_evidence.clear();
2231 }
2232 run.attempts[3]
2233 .evidence
2234 .extend(["art_retry_action_1".into(), "art_retry_action_2".into()]);
2235 run.attempts[3].action_evidence =
2236 vec!["art_retry_action_1".into(), "art_retry_action_2".into()];
2237
2238 let mut declaration = valid_declaration();
2239 declaration.loops[0]
2240 .budget
2241 .as_mut()
2242 .expect("fixture loop has an action budget")
2243 .max_actions = Some(1);
2244
2245 let report = evaluate_workflow_conformance(&declaration, &run)
2246 .expect("verified signed action references are valid loop evidence");
2247 assert!(
2248 report.loops[0].budget_exceeded,
2249 "two retry actions exceed one action even when no tool label is present"
2250 );
2251 }
2252
2253 #[test]
2254 fn duplicate_or_unbound_action_evidence_is_rejected() {
2255 let fixture: serde_json::Value = serde_json::from_str(include_str!(
2256 "../../tests/fixtures/workflow-conformance/valid.json"
2257 ))
2258 .expect("valid fixture parses as JSON");
2259 let mut run: ObservedWorkflowRun =
2260 serde_json::from_value(fixture["run"].clone()).expect("valid fixture run parses");
2261 run.attempts[0].action_evidence = vec!["art_missing".into()];
2262 let error = evaluate_workflow_conformance(&valid_declaration(), &run)
2263 .expect_err("an action reference outside the attempt evidence cannot affect a budget");
2264 assert!(matches!(error, WorkflowConformanceError::InvalidRun(_)));
2265
2266 run.attempts[0].evidence.push("art_missing".into());
2267 run.attempts[0].action_evidence.push("art_missing".into());
2268 let error = evaluate_workflow_conformance(&valid_declaration(), &run)
2269 .expect_err("one signed action cannot be counted twice");
2270 assert!(matches!(error, WorkflowConformanceError::InvalidRun(_)));
2271 }
2272
2273 #[test]
2274 fn empty_observation_set_never_passes_vacuously() {
2275 let run = ObservedWorkflowRun {
2276 run_id: "run".into(),
2277 status: WorkflowRunStatus::Completed,
2278 workflow_ref: "art_workflow".into(),
2279 pre_existence: PreExistenceEvidence {
2280 grade: EvidenceGrade::Asserted,
2281 reason: Some("no checkpoint".into()),
2282 declaration_checkpoint: None,
2283 declaration_tree_size: None,
2284 first_run_leaf_index: None,
2285 consistency_to: None,
2286 declaration_signed_at: None,
2287 first_run_signed_at: None,
2288 },
2289 attempts: vec![],
2290 };
2291
2292 let error = evaluate_workflow_conformance(&valid_declaration(), &run)
2293 .expect_err("empty evidence cannot produce a clean report");
2294 assert!(matches!(error, WorkflowConformanceError::InvalidRun(_)));
2295 }
2296
2297 fn observed_golden_run() -> ObservedWorkflowRun {
2300 let fixture: serde_json::Value = serde_json::from_str(include_str!(
2301 "../../tests/fixtures/workflow-conformance/valid.json"
2302 ))
2303 .expect("golden fixture parses");
2304 serde_json::from_value(fixture["run"].clone()).expect("golden observed run parses")
2305 }
2306
2307 fn signed_declaration(declaration: &WorkflowDeclaration) -> (String, Envelope, Ed25519Signer) {
2310 let signer = Ed25519Signer::generate("key_workflow_authority")
2311 .expect("test signer generation succeeds");
2312 let mut receipt = ReceiptStatement::new("system://treeship-test", "workflow.v1");
2313 receipt.payload = Some(serde_json::to_value(declaration).expect("declaration serializes"));
2314 let result =
2315 sign(&payload_type("receipt"), &receipt, &signer).expect("workflow declaration signs");
2316 (result.artifact_id, result.envelope, signer)
2317 }
2318
2319 fn verifier_over(signers: &[&Ed25519Signer]) -> Verifier {
2320 use ed25519_dalek::VerifyingKey;
2321 let mut verifier = Verifier::new(std::collections::HashMap::new());
2322 for signer in signers {
2323 let bytes: [u8; 32] = signer
2324 .public_key_bytes()
2325 .try_into()
2326 .expect("Ed25519 public key is 32 bytes");
2327 verifier.add_key(
2328 signer.key_id(),
2329 VerifyingKey::from_bytes(&bytes).expect("test public key is valid"),
2330 );
2331 }
2332 verifier
2333 }
2334
2335 struct ComposedCase {
2338 declaration_envelope: Envelope,
2339 first_run_envelope: Envelope,
2340 proof: WorkflowPreExistenceProof,
2341 trust: TrustRootStore,
2342 verifier: Verifier,
2343 workflow_ref: String,
2344 run: ObservedWorkflowRun,
2345 }
2346
2347 fn composed_case() -> ComposedCase {
2348 let declaration = valid_declaration();
2349 let (workflow_ref, declaration_envelope, authority) = signed_declaration(&declaration);
2350
2351 let run_signer =
2352 Ed25519Signer::generate("key_workflow_run").expect("test signer generation succeeds");
2353 let mut action = ActionStatement::new("agent://claude-code", "session.start");
2354 action.meta = Some(serde_json::json!({
2355 "session_start": true,
2356 "workflow_ref": workflow_ref,
2357 }));
2358 let signed_run = sign(&payload_type("action"), &action, &run_signer)
2359 .expect("session start action signs");
2360
2361 let (proof, trust) = pre_existence_proof_for(&workflow_ref, &signed_run.artifact_id);
2362
2363 let mut run = observed_golden_run();
2364 run.workflow_ref = workflow_ref.clone();
2365
2366 ComposedCase {
2367 declaration_envelope,
2368 first_run_envelope: signed_run.envelope,
2369 proof,
2370 trust,
2371 verifier: verifier_over(&[&authority, &run_signer]),
2372 workflow_ref,
2373 run,
2374 }
2375 }
2376
2377 #[test]
2378 fn composed_path_discards_a_claimed_pre_existence_grade() {
2379 let case = composed_case();
2380 assert_eq!(case.run.pre_existence.grade, EvidenceGrade::Checked);
2384
2385 let report = verify_workflow_run(
2386 &case.declaration_envelope,
2387 &case.first_run_envelope,
2388 None,
2389 &case.run,
2390 &case.verifier,
2391 &case.trust,
2392 )
2393 .expect("a run with no checkpoint proof still produces a report");
2394
2395 assert_eq!(
2396 report.pre_existence.grade,
2397 EvidenceGrade::Asserted,
2398 "an unproven pre-existence claim must be downgraded, not trusted"
2399 );
2400 }
2401
2402 #[test]
2403 fn composed_path_grades_pre_existence_checked_only_with_real_proof() {
2404 let case = composed_case();
2405 let report = verify_workflow_run(
2406 &case.declaration_envelope,
2407 &case.first_run_envelope,
2408 Some(&case.proof),
2409 &case.run,
2410 &case.verifier,
2411 &case.trust,
2412 )
2413 .expect("real checkpoints over real artifact ids verify");
2414
2415 assert_eq!(report.pre_existence.grade, EvidenceGrade::Checked);
2416 assert_eq!(report.workflow_ref, case.workflow_ref);
2417 }
2418
2419 #[test]
2420 fn composed_path_refuses_a_declaration_signed_by_an_untrusted_key() {
2421 let case = composed_case();
2422 let stranger =
2423 Ed25519Signer::generate("key_stranger").expect("test signer generation succeeds");
2424 let verifier = verifier_over(&[&stranger]);
2425
2426 let error = verify_workflow_run(
2427 &case.declaration_envelope,
2428 &case.first_run_envelope,
2429 Some(&case.proof),
2430 &case.run,
2431 &verifier,
2432 &case.trust,
2433 )
2434 .expect_err("an unverifiable declaration must never reach the reducer");
2435 assert!(matches!(
2436 error,
2437 WorkflowRunVerifyError::DeclarationSignature(_)
2438 ));
2439 }
2440
2441 #[test]
2442 fn composed_path_refuses_a_run_that_names_another_workflow() {
2443 let case = composed_case();
2444 let mut run = case.run.clone();
2445 run.workflow_ref = "art_some_other_workflow".into();
2446
2447 let error = verify_workflow_run(
2448 &case.declaration_envelope,
2449 &case.first_run_envelope,
2450 Some(&case.proof),
2451 &run,
2452 &case.verifier,
2453 &case.trust,
2454 )
2455 .expect_err("an observation set for another workflow must be refused");
2456 assert!(matches!(
2457 error,
2458 WorkflowRunVerifyError::WorkflowRefMismatch { .. }
2459 ));
2460 }
2461
2462 #[test]
2463 fn composed_path_refuses_a_first_run_bound_to_another_workflow() {
2464 let case = composed_case();
2465 let (_, other_envelope, _) = {
2466 let run_signer = Ed25519Signer::generate("key_workflow_run")
2467 .expect("test signer generation succeeds");
2468 let mut action = ActionStatement::new("agent://claude-code", "session.start");
2469 action.meta = Some(serde_json::json!({
2470 "session_start": true,
2471 "workflow_ref": "art_some_other_workflow",
2472 }));
2473 let signed = sign(&payload_type("action"), &action, &run_signer)
2474 .expect("session start action signs");
2475 let verifier = verifier_over(&[&run_signer]);
2476 (verifier, signed.envelope, signed.artifact_id)
2477 };
2478
2479 let error = verify_workflow_run(
2480 &case.declaration_envelope,
2481 &other_envelope,
2482 None,
2483 &case.run,
2484 &case.verifier,
2485 &case.trust,
2486 )
2487 .expect_err("a session.start bound to a different workflow must be refused");
2488 assert!(matches!(error, WorkflowRunVerifyError::RunBinding(_)));
2489 }
2490
2491 #[test]
2492 fn composed_path_refuses_a_receipt_that_is_not_a_workflow_declaration() {
2493 let case = composed_case();
2494 let signer = Ed25519Signer::generate("key_workflow_authority")
2495 .expect("test signer generation succeeds");
2496 let mut receipt = ReceiptStatement::new("system://treeship-test", "memory.read.v1");
2497 receipt.payload = Some(serde_json::json!({ "note": "not a workflow" }));
2498 let signed = sign(&payload_type("receipt"), &receipt, &signer).expect("receipt signs");
2499
2500 let error = verify_workflow_run(
2501 &signed.envelope,
2502 &case.first_run_envelope,
2503 None,
2504 &case.run,
2505 &verifier_over(&[&signer]),
2506 &case.trust,
2507 )
2508 .expect_err("a non-workflow receipt must not be read as a declaration");
2509 assert!(matches!(
2510 error,
2511 WorkflowRunVerifyError::DeclarationNotAWorkflow { .. }
2512 ));
2513 }
2514
2515 fn action(artifact_id: &str, actor: &str, tool: &str) -> VerifiedAction {
2520 VerifiedAction {
2521 artifact_id: artifact_id.into(),
2522 actor: actor.into(),
2523 tool: tool.into(),
2524 capabilities: vec![],
2525 outcome: NodeOutcome::Completed,
2526 node_label: None,
2527 }
2528 }
2529
2530 fn ambiguous_declaration() -> WorkflowDeclaration {
2534 let mut declaration = valid_declaration();
2535 declaration.nodes.push(WorkflowNode {
2536 id: "review".into(),
2537 executor: ExecutorConstraint {
2538 actor: Some("agent://claude-code".into()),
2539 capability: None,
2540 },
2541 allowed_tools: vec!["Read".into(), "Grep".into()],
2542 });
2543 declaration.edges.push(WorkflowEdge {
2544 from: "inspect".into(),
2545 to: "review".into(),
2546 when: EdgeCondition::Always,
2547 });
2548 declaration.edges.push(WorkflowEdge {
2549 from: "review".into(),
2550 to: "change".into(),
2551 when: EdgeCondition::Always,
2552 });
2553 declaration
2554 }
2555
2556 #[test]
2557 fn unique_admissibility_ignores_a_label_that_names_another_node() {
2558 let mut lying = action("art_read", "agent://claude-code", "Read");
2563 lying.node_label = Some("change".into());
2564
2565 let derived = derive_observed_run(
2566 &valid_declaration(),
2567 "run_1",
2568 "art_workflow",
2569 WorkflowRunStatus::Completed,
2570 &[lying],
2571 )
2572 .expect("a uniquely admissible action derives");
2573
2574 assert!(derived.issues.is_empty(), "no attribution issue expected");
2575 assert_eq!(derived.run.attempts.len(), 1);
2576 assert_eq!(derived.run.attempts[0].node_id, "inspect");
2577 assert_eq!(
2578 derived.run.attempts[0].grade,
2579 EvidenceGrade::Checked,
2580 "recomputed from signed fields alone"
2581 );
2582 }
2583
2584 #[test]
2585 fn ambiguous_attribution_is_reported_instead_of_guessed() {
2586 let derived = derive_observed_run(
2587 &ambiguous_declaration(),
2588 "run_1",
2589 "art_workflow",
2590 WorkflowRunStatus::Completed,
2591 &[action("art_read", "agent://claude-code", "Read")],
2592 )
2593 .expect_err("an unattributable action set cannot become a run");
2594
2595 assert!(
2596 matches!(derived, DerivationError::NoAttributableActions { ref issues }
2597 if matches!(issues.as_slice(), [AttributionIssue::Ambiguous { candidates, .. }]
2598 if candidates == &["inspect".to_string(), "review".to_string()])),
2599 "expected an ambiguity issue naming both candidates, got {derived:?}"
2600 );
2601 }
2602
2603 #[test]
2604 fn a_label_narrows_an_admissible_set_but_caps_the_grade_at_captured() {
2605 let mut labeled = action("art_read", "agent://claude-code", "Read");
2606 labeled.node_label = Some("review".into());
2607
2608 let derived = derive_observed_run(
2609 &ambiguous_declaration(),
2610 "run_1",
2611 "art_workflow",
2612 WorkflowRunStatus::Completed,
2613 &[labeled],
2614 )
2615 .expect("a label may pick within the admissible set");
2616
2617 assert_eq!(derived.run.attempts[0].node_id, "review");
2618 assert_eq!(
2619 derived.run.attempts[0].grade,
2620 EvidenceGrade::Captured,
2621 "a runtime label cannot buy a checked grade"
2622 );
2623 }
2624
2625 #[test]
2626 fn a_label_outside_the_admissible_set_is_refused_as_a_tiebreak() {
2627 let mut lying = action("art_read", "agent://claude-code", "Read");
2631 lying.node_label = Some("qa".into());
2632
2633 let error = derive_observed_run(
2634 &ambiguous_declaration(),
2635 "run_1",
2636 "art_workflow",
2637 WorkflowRunStatus::Completed,
2638 &[lying],
2639 )
2640 .expect_err("a label outside the admissible set is refused");
2641
2642 assert!(
2643 matches!(error, DerivationError::NoAttributableActions { ref issues }
2644 if matches!(issues.as_slice(), [AttributionIssue::LabelNotAdmissible { label, .. }]
2645 if label == "qa")),
2646 "expected a non-admissible label issue, got {error:?}"
2647 );
2648 }
2649
2650 #[test]
2651 fn an_out_of_scope_tool_stays_visible_to_the_authority_axis() {
2652 let derived = derive_observed_run(
2657 &valid_declaration(),
2658 "run_1",
2659 "art_workflow",
2660 WorkflowRunStatus::Completed,
2661 &[
2662 action("art_read", "agent://claude-code", "Read"),
2663 action("art_bash", "agent://claude-code", "Bash"),
2664 ],
2665 )
2666 .expect("an out-of-scope tool is attributed, not dropped");
2667
2668 let report = evaluate_workflow_conformance(&valid_declaration(), &derived.run)
2669 .expect("the derived run reduces");
2670 assert!(
2671 report
2672 .authority
2673 .deviations
2674 .iter()
2675 .any(|d| d.kind == "tool_out_of_scope" && d.value == "Bash"),
2676 "the out-of-scope tool must reach the report: {:?}",
2677 report.authority.deviations
2678 );
2679 assert_eq!(report.authority.grade, EvidenceGrade::Captured);
2680 }
2681
2682 #[test]
2683 fn an_action_no_declared_executor_admits_is_surfaced() {
2684 let error = derive_observed_run(
2685 &valid_declaration(),
2686 "run_1",
2687 "art_workflow",
2688 WorkflowRunStatus::Completed,
2689 &[action("art_read", "agent://stranger", "Read")],
2690 )
2691 .expect_err("an undeclared executor cannot be attributed");
2692
2693 assert!(
2694 matches!(error, DerivationError::NoAttributableActions { ref issues }
2695 if matches!(issues.as_slice(), [AttributionIssue::UndeclaredExecutor { actor, .. }]
2696 if actor == "agent://stranger")),
2697 "expected an undeclared-executor issue, got {error:?}"
2698 );
2699 }
2700
2701 #[test]
2702 fn deriving_from_zero_actions_is_an_error_not_an_empty_passing_run() {
2703 let error = derive_observed_run(
2704 &valid_declaration(),
2705 "run_1",
2706 "art_workflow",
2707 WorkflowRunStatus::Completed,
2708 &[],
2709 )
2710 .expect_err("an empty observation set is refused");
2711
2712 assert!(matches!(error, DerivationError::NoActions));
2713 }
2714
2715 #[test]
2716 fn revisiting_a_node_opens_a_new_iteration_instead_of_merging() {
2717 let derived = derive_observed_run(
2718 &valid_declaration(),
2719 "run_1",
2720 "art_workflow",
2721 WorkflowRunStatus::Completed,
2722 &[
2723 action("art_edit_1", "agent://claude-code", "Edit"),
2724 VerifiedAction {
2725 capabilities: vec!["qa.browser".into()],
2726 ..action("art_qa_1", "agent://qa-runner", "gstack.qa")
2727 },
2728 action("art_edit_2", "agent://claude-code", "Write"),
2729 ],
2730 )
2731 .expect("a revisit derives");
2732
2733 let change: Vec<_> = derived
2734 .run
2735 .attempts
2736 .iter()
2737 .filter(|a| a.node_id == "change")
2738 .collect();
2739 assert_eq!(change.len(), 2, "the revisit is a second attempt");
2740 assert_eq!(change[0].iteration, 0);
2741 assert_eq!(change[1].iteration, 1);
2742 assert_eq!(change[1].evidence, vec!["art_edit_2".to_string()]);
2743 }
2744
2745 #[test]
2746 fn consecutive_actions_on_one_node_merge_into_a_single_attempt() {
2747 let derived = derive_observed_run(
2748 &valid_declaration(),
2749 "run_1",
2750 "art_workflow",
2751 WorkflowRunStatus::Completed,
2752 &[
2753 action("art_read", "agent://claude-code", "Read"),
2754 action("art_grep", "agent://claude-code", "Grep"),
2755 ],
2756 )
2757 .expect("consecutive same-node actions derive");
2758
2759 assert_eq!(derived.run.attempts.len(), 1);
2760 assert_eq!(derived.run.attempts[0].tools, vec!["Grep", "Read"]);
2761 assert_eq!(
2762 derived.run.attempts[0].action_evidence,
2763 vec!["art_read".to_string(), "art_grep".to_string()]
2764 );
2765 }
2766
2767 #[test]
2768 fn duplicate_action_references_fail_closed() {
2769 let error = derive_observed_run(
2772 &valid_declaration(),
2773 "run_1",
2774 "art_workflow",
2775 WorkflowRunStatus::Completed,
2776 &[
2777 action("art_read", "agent://claude-code", "Read"),
2778 action("art_read", "agent://claude-code", "Grep"),
2779 ],
2780 )
2781 .expect_err("a repeated action reference is refused");
2782
2783 assert!(
2784 matches!(error, DerivationError::DuplicateAction { ref artifact_id }
2785 if artifact_id == "art_read"),
2786 "expected a duplicate-action error, got {error:?}"
2787 );
2788 }
2789
2790 #[test]
2791 fn a_derived_run_never_claims_its_own_pre_existence() {
2792 let derived = derive_observed_run(
2793 &valid_declaration(),
2794 "run_1",
2795 "art_workflow",
2796 WorkflowRunStatus::Completed,
2797 &[action("art_read", "agent://claude-code", "Read")],
2798 )
2799 .expect("derives");
2800
2801 assert_eq!(
2802 derived.run.pre_existence.grade,
2803 EvidenceGrade::Asserted,
2804 "derivation sees no checkpoints; only verify_workflow_run grades ordering"
2805 );
2806 }
2807}