1use crate::{DrivenError, Result};
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4
5use super::redaction::redact_secrets;
6use super::{CommandEvidence, LaneClaim, PROOF_RECEIPT_SCHEMA, WorktreeIdentity};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ReceiptFormat {
10 Json,
11 Markdown,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum VerificationClass {
17 Small,
18 Targeted,
19 Heavy,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case", tag = "status")]
24pub enum CommandStatus {
25 Passed { exit_code: i32 },
26 Failed { exit_code: i32 },
27 Skipped { reason: String },
28 Blocked { reason: String },
29 NotRun,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct CommandProof {
34 pub command: String,
35 pub class: VerificationClass,
36 pub status: CommandStatus,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub evidence: Option<CommandEvidence>,
39}
40
41impl CommandProof {
42 pub fn new(
43 command: impl Into<String>,
44 class: VerificationClass,
45 status: CommandStatus,
46 ) -> Self {
47 Self {
48 command: command.into(),
49 class,
50 status,
51 evidence: None,
52 }
53 }
54
55 pub fn passed(command: impl Into<String>, class: VerificationClass) -> Self {
56 Self::new(command, class, CommandStatus::Passed { exit_code: 0 })
57 }
58
59 pub fn observed(
60 command: impl Into<String>,
61 class: VerificationClass,
62 exit_code: i32,
63 cwd: &Path,
64 stdout: &[u8],
65 stderr: &[u8],
66 started_unix_seconds: u64,
67 finished_unix_seconds: u64,
68 ) -> Result<Self> {
69 let status = if exit_code == 0 {
70 CommandStatus::Passed { exit_code }
71 } else {
72 CommandStatus::Failed { exit_code }
73 };
74 let proof = Self {
75 command: command.into(),
76 class,
77 status,
78 evidence: Some(CommandEvidence::from_streams(
79 cwd,
80 stdout,
81 stderr,
82 started_unix_seconds,
83 finished_unix_seconds,
84 )?),
85 };
86 proof.validate()?;
87 Ok(proof)
88 }
89
90 #[allow(clippy::too_many_arguments)]
91 pub fn observed_captured(
92 command: impl Into<String>,
93 class: VerificationClass,
94 exit_code: i32,
95 cwd: &Path,
96 stdout_digest: impl Into<String>,
97 stderr_digest: impl Into<String>,
98 stdout_bytes: u64,
99 stderr_bytes: u64,
100 stdout_truncated: bool,
101 stderr_truncated: bool,
102 output_limit_bytes: u64,
103 duration_ms: u64,
104 started_unix_seconds: u64,
105 finished_unix_seconds: u64,
106 ) -> Result<Self> {
107 let status = if exit_code == 0 {
108 CommandStatus::Passed { exit_code }
109 } else {
110 CommandStatus::Failed { exit_code }
111 };
112 let proof = Self {
113 command: command.into(),
114 class,
115 status,
116 evidence: Some(CommandEvidence::from_captured_streams(
117 cwd,
118 stdout_digest,
119 stderr_digest,
120 stdout_bytes,
121 stderr_bytes,
122 stdout_truncated,
123 stderr_truncated,
124 output_limit_bytes,
125 duration_ms,
126 started_unix_seconds,
127 finished_unix_seconds,
128 )?),
129 };
130 proof.validate()?;
131 Ok(proof)
132 }
133
134 fn validate(&self) -> Result<()> {
135 if self.command.trim().is_empty() {
136 return Err(DrivenError::Validation(
137 "command proof command cannot be empty".to_string(),
138 ));
139 }
140 if let Some(evidence) = &self.evidence {
141 if !matches!(
142 self.status,
143 CommandStatus::Passed { .. } | CommandStatus::Failed { .. }
144 ) {
145 return Err(DrivenError::Validation(
146 "command evidence can only be attached to passed or failed commands"
147 .to_string(),
148 ));
149 }
150 evidence.validate()?;
151 }
152 self.status.validate()
153 }
154
155 fn is_successful_small(&self) -> bool {
156 self.class == VerificationClass::Small
157 && matches!(self.status, CommandStatus::Passed { exit_code: 0 })
158 }
159
160 fn blocker(&self) -> Option<String> {
161 match &self.status {
162 CommandStatus::Blocked { reason } => Some(format!("command blocked: {}", reason)),
163 _ => None,
164 }
165 }
166}
167
168impl CommandStatus {
169 fn validate(&self) -> Result<()> {
170 match self {
171 Self::Passed { exit_code } if *exit_code == 0 => Ok(()),
172 Self::Passed { .. } => Err(DrivenError::Validation(
173 "passed command proof must use exit code 0".to_string(),
174 )),
175 Self::Failed { exit_code } if *exit_code != 0 => Ok(()),
176 Self::Failed { .. } => Err(DrivenError::Validation(
177 "failed command proof must use a non-zero exit code".to_string(),
178 )),
179 Self::Skipped { reason } | Self::Blocked { reason } => {
180 if reason.trim().is_empty() {
181 Err(DrivenError::Validation(
182 "skipped or blocked command proof requires a reason".to_string(),
183 ))
184 } else {
185 Ok(())
186 }
187 }
188 Self::NotRun => Ok(()),
189 }
190 }
191
192 fn prevents_verified_outcome(&self) -> bool {
193 matches!(
194 self,
195 Self::Failed { .. } | Self::Skipped { .. } | Self::Blocked { .. } | Self::NotRun
196 )
197 }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct FileProof {
202 pub path: String,
203 pub purpose: String,
204}
205
206impl FileProof {
207 pub fn new(path: impl Into<String>, purpose: impl Into<String>) -> Self {
208 Self {
209 path: normalize_path(path.into()),
210 purpose: purpose.into(),
211 }
212 }
213
214 fn validate(&self) -> Result<()> {
215 if self.path.trim().is_empty() {
216 return Err(DrivenError::Validation(
217 "file proof path cannot be empty".to_string(),
218 ));
219 }
220 if self.purpose.trim().is_empty() {
221 return Err(DrivenError::Validation(
222 "file proof purpose cannot be empty".to_string(),
223 ));
224 }
225 Ok(())
226 }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230#[serde(rename_all = "snake_case", tag = "status")]
231pub enum OutcomeProof {
232 Verified { summary: String },
233 Partial { summary: String },
234 Blocked { reason: String },
235}
236
237impl OutcomeProof {
238 pub fn verified(summary: impl Into<String>) -> Self {
239 Self::Verified {
240 summary: summary.into(),
241 }
242 }
243
244 pub fn partial(summary: impl Into<String>) -> Self {
245 Self::Partial {
246 summary: summary.into(),
247 }
248 }
249
250 pub fn blocked(reason: impl Into<String>) -> Self {
251 Self::Blocked {
252 reason: reason.into(),
253 }
254 }
255
256 fn validate(&self) -> Result<()> {
257 let text = match self {
258 Self::Verified { summary } | Self::Partial { summary } => summary,
259 Self::Blocked { reason } => reason,
260 };
261 if text.trim().is_empty() {
262 return Err(DrivenError::Validation(
263 "outcome proof text cannot be empty".to_string(),
264 ));
265 }
266 Ok(())
267 }
268
269 fn blocker(&self) -> Option<String> {
270 match self {
271 Self::Blocked { reason } => Some(reason.clone()),
272 _ => None,
273 }
274 }
275}
276
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278pub struct ProofReceipt {
279 pub schema: String,
280 pub receipt_id: String,
281 #[serde(default)]
282 pub redacted: bool,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub redacted_payload_digest: Option<String>,
285 pub claim: LaneClaim,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub worktree_identity: Option<WorktreeIdentity>,
288 pub summary: String,
289 pub commands: Vec<CommandProof>,
290 pub files: Vec<FileProof>,
291 pub outcomes: Vec<OutcomeProof>,
292}
293
294impl ProofReceipt {
295 pub fn new(claim: LaneClaim, summary: impl Into<String>) -> Self {
296 Self {
297 schema: PROOF_RECEIPT_SCHEMA.to_string(),
298 receipt_id: String::new(),
299 redacted: false,
300 redacted_payload_digest: None,
301 claim,
302 worktree_identity: None,
303 summary: summary.into(),
304 commands: Vec::new(),
305 files: Vec::new(),
306 outcomes: Vec::new(),
307 }
308 }
309
310 pub fn with_command(mut self, command: CommandProof) -> Self {
311 self.commands.push(command);
312 self
313 }
314
315 pub fn with_file(mut self, file: FileProof) -> Self {
316 self.files.push(file);
317 self
318 }
319
320 pub fn with_outcome(mut self, outcome: OutcomeProof) -> Self {
321 self.outcomes.push(outcome);
322 self
323 }
324
325 pub fn with_worktree_identity(mut self, identity: WorktreeIdentity) -> Self {
326 self.worktree_identity = Some(identity);
327 self
328 }
329
330 pub fn summary(&self) -> &str {
331 &self.summary
332 }
333
334 pub fn blockers(&self) -> Vec<String> {
335 let mut blockers: Vec<String> = self
336 .commands
337 .iter()
338 .filter_map(CommandProof::blocker)
339 .collect();
340 blockers.extend(self.outcomes.iter().filter_map(OutcomeProof::blocker));
341 blockers
342 }
343
344 pub fn validate(&self) -> Result<()> {
345 self.claim.validate()?;
346 if self.schema != PROOF_RECEIPT_SCHEMA {
347 return Err(DrivenError::Validation(format!(
348 "proof receipt schema must be {}",
349 PROOF_RECEIPT_SCHEMA
350 )));
351 }
352 if self.summary.trim().is_empty() {
353 return Err(DrivenError::Validation(
354 "receipt summary cannot be empty".to_string(),
355 ));
356 }
357 if self.redacted {
358 if self.redacted_payload_digest.as_deref() != Some(&self.rendered_payload_digest()?) {
359 return Err(DrivenError::Validation(
360 "redacted receipt digest does not match rendered payload".to_string(),
361 ));
362 }
363 } else if !self.receipt_id.trim().is_empty()
364 && self.receipt_id != self.computed_receipt_id()?
365 {
366 return Err(DrivenError::Validation(
367 "receipt id does not match canonical receipt payload".to_string(),
368 ));
369 }
370 if self.outcomes.is_empty() {
371 return Err(DrivenError::Validation(
372 "receipt requires at least one outcome proof".to_string(),
373 ));
374 }
375
376 for command in &self.commands {
377 command.validate()?;
378 }
379 for file in &self.files {
380 file.validate()?;
381 }
382 for outcome in &self.outcomes {
383 outcome.validate()?;
384 }
385
386 let mut has_passed_small = false;
387 for command in &self.commands {
388 match command.class {
389 VerificationClass::Small if command.is_successful_small() => {
390 has_passed_small = true;
391 }
392 VerificationClass::Targeted | VerificationClass::Heavy if !has_passed_small => {
393 return Err(DrivenError::Validation(
394 "targeted or heavy verification requires a passed small command proof first"
395 .to_string(),
396 ));
397 }
398 _ => {}
399 }
400 }
401
402 let has_verified_outcome = self
403 .outcomes
404 .iter()
405 .any(|outcome| matches!(outcome, OutcomeProof::Verified { .. }));
406 if has_verified_outcome
407 && !self
408 .commands
409 .iter()
410 .any(|command| matches!(command.status, CommandStatus::Passed { exit_code: 0 }))
411 {
412 return Err(DrivenError::Validation(
413 "verified receipt requires at least one passed command proof".to_string(),
414 ));
415 }
416 if has_verified_outcome
417 && self
418 .commands
419 .iter()
420 .any(|command| command.status.prevents_verified_outcome())
421 {
422 return Err(DrivenError::Validation(
423 "verified receipt cannot include failed, blocked, or not-run commands".to_string(),
424 ));
425 }
426
427 if self
428 .commands
429 .iter()
430 .any(|command| matches!(command.status, CommandStatus::Blocked { .. }))
431 && !self
432 .outcomes
433 .iter()
434 .any(|outcome| matches!(outcome, OutcomeProof::Blocked { .. }))
435 {
436 return Err(DrivenError::Validation(
437 "blocked command proof requires a blocked outcome".to_string(),
438 ));
439 }
440
441 Ok(())
442 }
443
444 pub fn is_verified(&self) -> bool {
445 self.outcomes
446 .iter()
447 .any(|outcome| matches!(outcome, OutcomeProof::Verified { .. }))
448 }
449
450 pub(crate) fn validate_completion_readiness(&self) -> Result<()> {
451 self.validate()?;
452 if self.redacted {
453 return Err(DrivenError::Validation(
454 "completion requires an unredacted canonical proof receipt".to_string(),
455 ));
456 }
457 if self.receipt_id.trim().is_empty() {
458 return Err(DrivenError::Validation(
459 "completion requires a canonical receipt id".to_string(),
460 ));
461 }
462 if !self.is_verified() {
463 return Err(DrivenError::Validation(
464 "completion requires a verified proof receipt".to_string(),
465 ));
466 }
467 if self
468 .outcomes
469 .iter()
470 .any(|outcome| !matches!(outcome, OutcomeProof::Verified { .. }))
471 {
472 return Err(DrivenError::Validation(
473 "completion receipt cannot include partial or blocked outcomes".to_string(),
474 ));
475 }
476 if self
477 .commands
478 .iter()
479 .any(|command| command.evidence.is_none())
480 {
481 return Err(DrivenError::Validation(
482 "completion command proofs require driven-captured evidence".to_string(),
483 ));
484 }
485 Ok(())
486 }
487
488 pub(crate) fn validate_completion_claim_subject(&self, claim: &LaneClaim) -> Result<()> {
489 if self.claim.lane != claim.lane
490 || self.claim.pass != claim.pass
491 || self.claim.worker_id != claim.worker_id
492 {
493 return Err(DrivenError::Validation(
494 "receipt claim does not match active lane claim".to_string(),
495 ));
496 }
497 Ok(())
498 }
499
500 pub(crate) fn validate_completion_claim_match(&self, claim: &LaneClaim) -> Result<()> {
501 self.validate_completion_claim_subject(claim)?;
502 if self.claim.token != claim.token {
503 return Err(DrivenError::Validation(
504 "receipt claim does not match active lane claim".to_string(),
505 ));
506 }
507 Ok(())
508 }
509
510 pub fn validate_for_completion_claim(&self, claim: &LaneClaim) -> Result<()> {
511 self.validate_completion_readiness()?;
512 self.validate_completion_claim_match(claim)
513 }
514
515 pub fn validate_worktree_identity(&self, expected: &WorktreeIdentity) -> Result<()> {
516 self.validate()?;
517 let Some(actual) = &self.worktree_identity else {
518 return Err(DrivenError::Validation(
519 "receipt worktree identity is required".to_string(),
520 ));
521 };
522 if actual != expected {
523 return Err(DrivenError::Validation(
524 "receipt worktree identity does not match active lane claim".to_string(),
525 ));
526 }
527 Ok(())
528 }
529
530 pub fn receipt_id(&self) -> Result<String> {
531 self.validate()?;
532 self.computed_receipt_id()
533 }
534
535 fn computed_receipt_id(&self) -> Result<String> {
536 let mut canonical = self.clone();
537 canonical.receipt_id.clear();
538 canonical.redacted = false;
539 canonical.redacted_payload_digest = None;
540 let bytes = serde_json::to_vec(&canonical)
541 .map_err(|e| DrivenError::Format(format!("failed to render receipt JSON: {}", e)))?;
542 Ok(blake3::hash(&bytes).to_hex().to_string())
543 }
544
545 fn rendered_payload_digest(&self) -> Result<String> {
546 let mut canonical = self.clone();
547 canonical.receipt_id.clear();
548 canonical.redacted_payload_digest = None;
549 let bytes = serde_json::to_vec(&canonical)
550 .map_err(|e| DrivenError::Format(format!("failed to render receipt JSON: {}", e)))?;
551 Ok(blake3::hash(&bytes).to_hex().to_string())
552 }
553
554 pub fn render(&self, format: ReceiptFormat) -> Result<String> {
555 self.validate()?;
556 let canonical_id = self.receipt_id()?;
557 let mut receipt = self.redacted_for_render();
558 receipt.receipt_id = canonical_id;
559 receipt.redacted = true;
560 receipt.redacted_payload_digest = Some(receipt.rendered_payload_digest()?);
561
562 match format {
563 ReceiptFormat::Json => serde_json::to_string_pretty(&receipt)
564 .map(|mut json| {
565 json.push('\n');
566 json
567 })
568 .map_err(|e| DrivenError::Format(format!("failed to render receipt JSON: {}", e))),
569 ReceiptFormat::Markdown => Ok(receipt.render_markdown()),
570 }
571 }
572
573 fn render_markdown(&self) -> String {
574 let mut out = String::new();
575 out.push_str(&format!("# DX Proof Receipt: {}\n\n", self.receipt_id));
576 out.push_str(&format!("Status: {}\n", receipt_status_label(self)));
577 out.push_str(&format!(
578 "Scope: lane {} / pass {} / worker {}\n\n",
579 self.claim.lane, self.claim.pass, self.claim.worker_id
580 ));
581
582 if let Some(identity) = &self.worktree_identity {
583 out.push_str("## Worktree\n");
584 out.push_str(&format!("- Kind: {:?}\n", identity.kind));
585 out.push_str(&format!(
586 "- Root: {}\n",
587 escape_markdown_text(&identity.input_root.display().to_string())
588 ));
589 if let Some(branch) = &identity.branch {
590 out.push_str(&format!("- Branch: {}\n", escape_markdown_text(branch)));
591 }
592 if let Some(remote) = &identity.remote {
593 out.push_str(&format!("- Remote: {}\n", escape_markdown_text(remote)));
594 }
595 out.push('\n');
596 }
597
598 out.push_str("## Summary\n");
599 out.push_str(&format!("- {}\n\n", escape_markdown_text(&self.summary)));
600
601 out.push_str("## Commands\n");
602 out.push_str("| # | Class | Status | Evidence | Command |\n");
603 out.push_str("|---|---|---|---|---|\n");
604 for (index, command) in self.commands.iter().enumerate() {
605 out.push_str(&format!(
606 "| {} | {:?} | {} | {} | {} |\n",
607 index + 1,
608 command.class,
609 command_status_label(&command.status),
610 command_evidence_label(command.evidence.as_ref()),
611 escape_table_cell(&command.command)
612 ));
613 }
614 if self.commands.is_empty() {
615 out.push_str("| - | - | not_run | - | - |\n");
616 }
617 out.push('\n');
618
619 out.push_str("## Files\n");
620 out.push_str("| Path | Purpose |\n");
621 out.push_str("|---|---|\n");
622 for file in &self.files {
623 out.push_str(&format!(
624 "| {} | {} |\n",
625 escape_table_cell(&file.path),
626 escape_table_cell(&file.purpose)
627 ));
628 }
629 if self.files.is_empty() {
630 out.push_str("| - | - |\n");
631 }
632 out.push('\n');
633
634 out.push_str("## Outcomes\n");
635 for outcome in &self.outcomes {
636 out.push_str(&format!("- {}\n", outcome_label(outcome)));
637 }
638 if self.outcomes.is_empty() {
639 out.push_str("- No outcome proof recorded.\n");
640 }
641 out.push('\n');
642
643 out.push_str("## Digest\n");
644 out.push_str("- Algorithm: blake3\n");
645 out.push_str("- Canonicalization: driven_receipt_v1\n");
646 if self.redacted {
647 out.push_str(&format!("- Canonical receipt: `{}`\n", self.receipt_id));
648 if let Some(redacted_payload_digest) = &self.redacted_payload_digest {
649 out.push_str(&format!(
650 "- Redacted payload: `{}`\n",
651 redacted_payload_digest
652 ));
653 }
654 } else {
655 out.push_str(&format!("- Value: `{}`\n", self.receipt_id));
656 }
657 out
658 }
659
660 fn redacted_for_render(&self) -> Self {
661 let mut receipt = self.clone();
662 receipt.receipt_id.clear();
663 receipt.redacted = false;
664 receipt.redacted_payload_digest = None;
665 receipt.summary = redact_secrets(&receipt.summary);
666 receipt.claim.scope = redact_secrets(&receipt.claim.scope);
667 receipt.claim.refresh_token();
668 if let Some(identity) = &mut receipt.worktree_identity {
669 identity.input_root = redacted_path(&identity.input_root);
670 identity.worktree_root = identity
671 .worktree_root
672 .as_ref()
673 .map(|path| redacted_path(path));
674 identity.git_dir = identity.git_dir.as_ref().map(|path| redacted_path(path));
675 identity.common_dir = identity.common_dir.as_ref().map(|path| redacted_path(path));
676 identity.superproject_root = identity
677 .superproject_root
678 .as_ref()
679 .map(|path| redacted_path(path));
680 identity.branch = identity
681 .branch
682 .as_ref()
683 .map(|branch| redact_secrets(branch));
684 identity.remote = identity
685 .remote
686 .as_ref()
687 .map(|remote| redact_secrets(remote));
688 }
689
690 for subagent in &mut receipt.claim.subagents {
691 subagent.task = redact_secrets(&subagent.task);
692 }
693 for command in &mut receipt.commands {
694 command.command = redact_secrets(&command.command);
695 if let Some(evidence) = &mut command.evidence {
696 evidence.cwd = redact_secrets(&evidence.cwd);
697 }
698 match &mut command.status {
699 CommandStatus::Skipped { reason } | CommandStatus::Blocked { reason } => {
700 *reason = redact_secrets(reason);
701 }
702 _ => {}
703 }
704 }
705 for file in &mut receipt.files {
706 file.path = redact_secrets(&file.path);
707 file.purpose = redact_secrets(&file.purpose);
708 }
709 for outcome in &mut receipt.outcomes {
710 match outcome {
711 OutcomeProof::Verified { summary } | OutcomeProof::Partial { summary } => {
712 *summary = redact_secrets(summary);
713 }
714 OutcomeProof::Blocked { reason } => {
715 *reason = redact_secrets(reason);
716 }
717 }
718 }
719 receipt
720 }
721}
722
723fn normalize_path(path: String) -> String {
724 path.replace('\\', "/")
725}
726
727fn redacted_path(path: &Path) -> std::path::PathBuf {
728 Path::new(&redact_secrets(&path.display().to_string())).to_path_buf()
729}
730
731fn escape_table_cell(value: &str) -> String {
732 escape_markdown_text(value)
733}
734
735pub(crate) fn escape_markdown_text(value: &str) -> String {
736 value
737 .replace('\r', "")
738 .replace('|', "\\|")
739 .replace('\n', "<br>")
740}
741
742fn command_status_label(status: &CommandStatus) -> String {
743 match status {
744 CommandStatus::Passed { exit_code } => format!("passed({})", exit_code),
745 CommandStatus::Failed { exit_code } => format!("failed({})", exit_code),
746 CommandStatus::Skipped { reason } => format!("skipped: {}", escape_markdown_text(reason)),
747 CommandStatus::Blocked { reason } => format!("blocked: {}", escape_markdown_text(reason)),
748 CommandStatus::NotRun => "not_run".to_string(),
749 }
750}
751
752fn command_evidence_label(evidence: Option<&CommandEvidence>) -> String {
753 match evidence {
754 Some(evidence) => format!(
755 "cwd: {}; stdout: {}; stderr: {}",
756 escape_markdown_text(&evidence.cwd),
757 evidence.stdout_digest,
758 evidence.stderr_digest
759 ),
760 None => "-".to_string(),
761 }
762}
763
764fn outcome_label(outcome: &OutcomeProof) -> String {
765 match outcome {
766 OutcomeProof::Verified { summary } => {
767 format!("verified: {}", escape_markdown_text(summary))
768 }
769 OutcomeProof::Partial { summary } => format!("partial: {}", escape_markdown_text(summary)),
770 OutcomeProof::Blocked { reason } => format!("blocked: {}", escape_markdown_text(reason)),
771 }
772}
773
774fn receipt_status_label(receipt: &ProofReceipt) -> &'static str {
775 if !receipt.blockers().is_empty() {
776 "blocked"
777 } else if receipt
778 .outcomes
779 .iter()
780 .any(|outcome| matches!(outcome, OutcomeProof::Partial { .. }))
781 || receipt.commands.iter().any(|command| {
782 matches!(
783 command.status,
784 CommandStatus::Failed { .. }
785 | CommandStatus::Skipped { .. }
786 | CommandStatus::NotRun
787 )
788 })
789 {
790 "partial"
791 } else {
792 "verified"
793 }
794}