Skip to main content

driven/strategy/
receipt.rs

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}