Skip to main content

treeship_core/verify/
workflow_conformance.rs

1//! Pure workflow conformance reduction.
2//!
3//! This module does not verify signatures, checkpoints, or trust roots. It
4//! consumes observations whose provenance has already been graded by the
5//! verifier and compares them with a validated `workflow.v1` declaration.
6//! Keeping that boundary explicit prevents a runtime's self-reported edge from
7//! becoming "checked" merely because it reached this reducer.
8
9use 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/// Minimal signed authorization graph for workflow conformance v1.
22#[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/// Exactly one field must be present. Validation rejects neither or both.
46#[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/// Provenance accepted by the reducer. `Checked` is strongest and `Asserted`
97/// weakest. Aggregates always inherit the weakest required evidence.
98#[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/// Normalized, already-verified input for one workflow run.
118#[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/// The upstream verifier's result for declaration ordering. The reducer checks
138/// that a `checked` grade carries the required basis, but does not re-verify
139/// checkpoint signatures or consistency proofs itself.
140#[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    /// Verified signed action artifact references attributed to this attempt.
175    /// Loop action budgets count these references, never tool labels.
176    #[serde(default, skip_serializing_if = "Vec::is_empty")]
177    pub action_evidence: Vec<String>,
178    /// Optional precise receipt references per tool. When absent, a finding
179    /// references all evidence for the attempt rather than inventing a tighter
180    /// binding than the input supports.
181    #[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/// Real Merkle evidence that the workflow declaration was in an earlier tree
268/// prefix than the first run artifact.
269#[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/// Failure to establish that the first signed run artifact selected a specific
348/// workflow declaration. This is separate from checkpoint ordering: inclusion
349/// proves when two artifacts entered a log, while this check proves the run
350/// itself names the declaration.
351#[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
667/// Verify that a trusted, signed `session.start` action is the claimed first
668/// run artifact and binds that run to `expected_workflow_ref`.
669///
670/// The workflow reference lives inside `ActionStatement::meta`, which is part
671/// of the DSSE payload. Mutating it changes the PAE digest and invalidates the
672/// signature. This function deliberately requires the caller's verifier rather
673/// than treating the envelope's key id as trust by itself.
674pub 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
721/// Verify that a workflow declaration was committed to the same append-only
722/// log before the first run artifact.
723///
724/// This checks both checkpoint signatures against the caller's trust roots,
725/// both inclusion proofs, leaf ordering, and the consistency proof joining the
726/// checkpoints. It does not verify the declaration or run artifact envelopes;
727/// callers must do that separately before treating their contents as signed.
728pub 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
880/// Compare an already-verified observation set with a workflow declaration.
881///
882/// No cryptographic verification occurs here. In particular, callers must not
883/// label `PreExistenceEvidence` or an attempt `checked` until the relevant
884/// signatures, trust roots, and checkpoint proofs have been verified.
885pub 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    // V1's action budget covers retry work after the first back-edge. The
1160    // initial forward pass is not a loop iteration. This count comes from
1161    // verified signed action references, never tool labels or the adapter's
1162    // iteration field.
1163    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/// Every way the composed workflow verification path can refuse before a
1289/// report exists. Each variant is a refusal, never a downgrade: the only
1290/// downgrade in this path is pre-existence, which becomes `asserted` when no
1291/// checkpoint proof is supplied.
1292#[derive(Debug, Clone, PartialEq, Eq)]
1293pub enum WorkflowRunVerifyError {
1294    /// The declaration envelope did not verify against the caller's verifier.
1295    DeclarationSignature(String),
1296    /// The declaration envelope is not a receipt.
1297    DeclarationWrongPayloadType { actual: String },
1298    /// The declaration envelope's statement did not parse.
1299    DeclarationMalformed(String),
1300    /// The receipt is signed, but it is not a `workflow.v1` declaration.
1301    DeclarationNotAWorkflow { actual: String },
1302    /// A `workflow.v1` receipt carried no payload to read a declaration from.
1303    DeclarationMissingPayload,
1304    /// The payload is not a well-formed declaration.
1305    InvalidDeclaration(Vec<WorkflowValidationError>),
1306    /// The observation set names a different workflow than the one signed.
1307    WorkflowRefMismatch { declaration: String, run: String },
1308    /// The first-run artifact did not verify, or does not bind this workflow.
1309    RunBinding(WorkflowRunBindingError),
1310    /// A pre-existence proof was supplied and did not hold.
1311    PreExistence(WorkflowPreExistenceError),
1312    /// The declaration and observations are individually sound, but the run
1313    /// itself is not reducible (vacuous or self-contradicting).
1314    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
1362/// Verify one workflow run end to end and return its conformance report.
1363///
1364/// This is the fail-closed composition of the pieces above, and it exists so
1365/// that no caller has to remember the order. In particular it is the only
1366/// place that decides the `pre_existence` grade: whatever the observation set
1367/// claims is **discarded** and replaced with what the supplied evidence
1368/// actually proves. A hand-written run file that asserts `checked` therefore
1369/// reports `asserted`, which is the difference between a verifier and a
1370/// formatter.
1371///
1372/// The steps, each fatal:
1373///
1374/// 1. the declaration envelope verifies, is a receipt, and is `workflow.v1`;
1375/// 2. the declaration is structurally valid;
1376/// 3. the observation set names that same declaration;
1377/// 4. the first-run artifact verifies and its signed `session.start` binds
1378///    this workflow;
1379/// 5. when a proof is supplied, both checkpoints, both inclusion proofs, leaf
1380///    ordering, log identity, and the consistency proof hold.
1381///
1382/// Only then does the pure reducer run. Omitting the proof is allowed and
1383/// costs the run its `checked` pre-existence grade; it is never an error,
1384/// because an unproven ordering claim is a weaker report rather than a
1385/// malformed one.
1386pub 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    // The workflow reference is the id of the artifact whose signature we just
1423    // checked, never a caller-supplied string.
1424    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    // Same rule for the run: identify the first-run artifact by verifying it,
1433    // then require the inclusion proof to be about that same id.
1434    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// ---------------------------------------------------------------------------
1472// Evidence-derived observation sets
1473// ---------------------------------------------------------------------------
1474
1475/// One verified action, reduced to the signed fields attribution depends on.
1476///
1477/// Signature verification, trust policy, and causal ordering happen upstream.
1478/// This is the already-trusted projection the deriver is allowed to reason
1479/// about, which is why every field here is one the signer committed to.
1480#[derive(Debug, Clone, PartialEq, Eq)]
1481pub struct VerifiedAction {
1482    /// Artifact id of the verified action. Becomes the attempt's evidence
1483    /// reference, so it must not be empty.
1484    pub artifact_id: String,
1485    pub actor: String,
1486    /// The signed action label, matched against a node's `allowed_tools`.
1487    pub tool: String,
1488    /// Capabilities the verified mandate carried.
1489    pub capabilities: Vec<String>,
1490    pub outcome: NodeOutcome,
1491    /// Grouping hint recorded by the runtime under review. It may pick among
1492    /// nodes the signed fields already admit; it can never name a node they do
1493    /// not, never applies when attribution is already unambiguous, and never
1494    /// lifts an attempt above `captured`.
1495    pub node_label: Option<String>,
1496}
1497
1498/// An action the declaration could not unambiguously place. Reported, never
1499/// guessed away: a run that silently drops what it cannot attribute is a
1500/// verifier that passes because it looked at less.
1501#[derive(Debug, Clone, PartialEq, Eq)]
1502pub enum AttributionIssue {
1503    /// More than one node admits the action and no label picks among them.
1504    Ambiguous {
1505        artifact_id: String,
1506        candidates: Vec<String>,
1507    },
1508    /// A label named a node that cannot admit the action.
1509    LabelNotAdmissible {
1510        artifact_id: String,
1511        label: String,
1512        candidates: Vec<String>,
1513    },
1514    /// No declared node's executor admits the signer at all.
1515    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    /// Zero verified actions. An empty observation set is refused rather than
1560    /// reduced into a vacuously clean report.
1561    NoActions,
1562    EmptyArtifactId {
1563        index: usize,
1564    },
1565    /// The same verified action appears twice. Loop budgets count unique
1566    /// signed-action references, so a repeat would inflate a budget with one
1567    /// action counted twice.
1568    DuplicateAction {
1569        artifact_id: String,
1570    },
1571    /// Every action failed attribution, so there is nothing to reduce.
1572    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/// A derived observation set plus everything the derivation refused to place.
1609#[derive(Debug, Clone, PartialEq, Eq)]
1610pub struct DerivedObservation {
1611    pub run: ObservedWorkflowRun,
1612    /// Actions that could not be attributed. Non-empty means the run is
1613    /// incomplete evidence, not a clean run: callers must surface these.
1614    pub issues: Vec<AttributionIssue>,
1615}
1616
1617/// Build an [`ObservedWorkflowRun`] from verified actions and a declaration.
1618///
1619/// Attribution is by admissibility, not by label. A node admits an action when
1620/// its executor matches the signed actor (or a capability the verified mandate
1621/// carried) and its `allowed_tools` covers the signed action label. Exactly one
1622/// admitting node means the verifier recomputed the attribution from signed
1623/// fields alone, which is the only case that earns `checked`.
1624///
1625/// The deliberate asymmetries:
1626///
1627/// - A label is consulted **only** when the signed fields leave a choice, so a
1628///   runtime cannot relabel work the declaration already places.
1629/// - A label that names a non-admitting node is an issue, never a tiebreak.
1630/// - An action no node's `allowed_tools` covers is still attributed by executor
1631///   -- to the node the run is currently in when that node admits the signer --
1632///   so the authority axis reports the out-of-scope tool instead of the action
1633///   vanishing into an issue list nobody reads.
1634/// - Pre-existence is left `asserted`. Derivation sees no checkpoints, and
1635///   `verify_workflow_run` is the only place that grades ordering.
1636pub 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        // The last action decides the attempt's outcome, and the weakest
1714        // attribution decides its grade.
1715        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
1748/// Decide which declared node an action belongs to. See
1749/// [`derive_observed_run`] for why the label is consulted where it is.
1750fn 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        // Recomputed from signed fields alone; the label is not consulted.
1775        1 => Ok((admitting[0].id.clone(), EvidenceGrade::Checked)),
1776        0 => {
1777            // No node claims this tool. Keep the action attached to the run so
1778            // the authority axis reports it, preferring the node the run is
1779            // already in when that node admits this signer.
1780            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        // Validation rejects a declaration with neither, so nothing admits.
1799        (None, None) => false,
1800    }
1801}
1802
1803/// Let the recorded label pick within a set the signed fields already allow.
1804/// A label naming anything outside that set is an issue, not a choice.
1805fn 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        // The label only narrowed an already-admissible set, so the attempt
1818        // rests partly on a runtime claim and cannot be `checked`.
1819        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    /// Build real checkpoints, inclusion proofs, and a consistency proof over
1848    /// two concrete artifact ids, so a composed test can use the ids that were
1849    /// actually signed rather than fixture placeholders.
1850    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    // ---- composed fail-closed path (slice 2) ----
2298
2299    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    /// Sign a declaration as a `workflow.v1` receipt and return its real
2308    /// artifact id, envelope, and the authority signer.
2309    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    /// A whole composed case: real signed declaration, real signed
2336    /// `session.start`, real checkpoints over those two real artifact ids.
2337    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        // The golden run asserts `checked` pre-existence with placeholder
2381        // checkpoint ids. With no proof supplied, the composed path must not
2382        // let that claim through.
2383        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    // -----------------------------------------------------------------------
2516    // Evidence-derived observation sets
2517    // -----------------------------------------------------------------------
2518
2519    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    /// Two nodes that admit exactly the same actor and tool. Attribution
2531    /// cannot be recomputed from signed fields alone once this exists, which
2532    /// is what makes the label cases below reachable at all.
2533    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        // `Read` by claude-code is admitted by `inspect` alone, so the
2559        // declaration already decides attribution. A runtime claiming the work
2560        // happened in `change` must not be able to move it: a label is a
2561        // grouping hint, never a relabeling power.
2562        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        // `qa` runs under a capability this action never carried. A label
2628        // pointing there is evidence of a deviation, not a tiebreak, and must
2629        // not silently attribute the action to a node that cannot admit it.
2630        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        // The trap: dropping an action no node's `allowed_tools` admits would
2653        // produce a clean report for a run that used an out-of-scope tool.
2654        // Executor match alone attributes it, so the authority axis still sees
2655        // the tool -- and the attempt cannot claim `checked`.
2656        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        // Loop budgets count unique signed-action references. Accepting the
2770        // same artifact twice would let one action pay for two.
2771        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}