Skip to main content

ftui_widgets/
receipt_verifier_panel.rs

1#![forbid(unsafe_code)]
2
3//! Receipt verifier panel widget (bd-cixqu.1.8 / Track A.7.1).
4//!
5//! Renders the operator-facing verifier verdict for a signed decision receipt,
6//! mirroring `runbooks/scripts/verify_receipt.sh` inside the TUI. The panel
7//! takes a parsed [`ReceiptVerdict`] (build it from the verifier's JSON
8//! output, or programmatically) and displays:
9//!
10//! - the overall verdict (receipt id + PASS/FAIL + failure_class on failure);
11//! - the three verification layers (signature / transparency / attestation)
12//!   each as PASS/FAIL with its `error_code`;
13//! - optional posterior path the receipt binds (`show_posterior_path`);
14//! - optional transparency-log inclusion + signature evidence chain
15//!   (`show_evidence_chain`);
16//! - warnings; and a triage block keyed off the `failure_class`.
17//!
18//! The panel intentionally keeps its data model self-contained (no engine
19//! dependency) so callers can construct it from any source — the verifier's
20//! `UnifiedReceiptVerificationVerdict` JSON, fixtures, mocks for previews,
21//! etc. The `verify_receipt.sh` verdict shape maps field-for-field onto the
22//! types defined here.
23
24use crate::borders::{BorderSet, BorderType};
25use crate::{Widget, apply_style, clear_text_area, draw_text_span};
26use ftui_core::geometry::Rect;
27use ftui_render::cell::{Cell, PackedRgba};
28use ftui_render::frame::Frame;
29use ftui_style::Style;
30
31// ---------------------------------------------------------------------------
32// Colour palette (mirrors decision_card)
33// ---------------------------------------------------------------------------
34
35const PASS_FG: PackedRgba = PackedRgba::rgb(0, 200, 0);
36const PASS_BG: PackedRgba = PackedRgba::rgb(0, 60, 0);
37const FAIL_FG: PackedRgba = PackedRgba::rgb(220, 50, 50);
38const FAIL_BG: PackedRgba = PackedRgba::rgb(60, 10, 10);
39const WARN_FG: PackedRgba = PackedRgba::rgb(220, 200, 0);
40const HEADER_FG: PackedRgba = PackedRgba::rgb(140, 160, 180);
41const DIM_FG: PackedRgba = PackedRgba::rgb(120, 120, 120);
42const DETAIL_FG: PackedRgba = PackedRgba::rgb(160, 180, 200);
43
44// ---------------------------------------------------------------------------
45// Data model — mirrors the verifier verdict JSON shape
46// ---------------------------------------------------------------------------
47
48/// Outcome of a single layer-internal check.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum CheckOutcome {
51    Pass,
52    Fail,
53    Warn,
54}
55
56impl CheckOutcome {
57    /// Parse an `outcome` string as emitted by the verifier (`pass`/`fail`/`warn`).
58    /// Unknown values are treated as `Warn` so they remain visible.
59    #[must_use]
60    pub fn parse(s: &str) -> Self {
61        match s {
62            "pass" | "PASS" => Self::Pass,
63            "fail" | "FAIL" => Self::Fail,
64            _ => Self::Warn,
65        }
66    }
67
68    /// Short label suitable for rendering (`PASS` / `FAIL` / `WARN`).
69    #[must_use]
70    pub fn label(self) -> &'static str {
71        match self {
72            Self::Pass => "PASS",
73            Self::Fail => "FAIL",
74            Self::Warn => "WARN",
75        }
76    }
77}
78
79/// One layer-internal check (signature/transparency/attestation sub-check).
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct CheckEntry {
82    pub check: String,
83    pub outcome: CheckOutcome,
84    pub error_code: Option<String>,
85    pub detail: String,
86}
87
88/// Verdict for one of the three verification layers.
89#[derive(Debug, Clone, PartialEq, Eq, Default)]
90pub struct LayerVerdict {
91    pub passed: bool,
92    pub error_code: Option<String>,
93    pub checks: Vec<CheckEntry>,
94}
95
96/// Failure classification when `passed == false`.
97///
98/// Matches the verifier's `failure_class` field — `None` means the receipt
99/// verified.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum FailureClass {
102    Signature,
103    Transparency,
104    Attestation,
105    StaleData,
106}
107
108impl FailureClass {
109    /// Parse the JSON failure_class string. Returns `None` for null/missing.
110    #[must_use]
111    pub fn parse(s: &str) -> Option<Self> {
112        match s {
113            "Signature" => Some(Self::Signature),
114            "Transparency" => Some(Self::Transparency),
115            "Attestation" => Some(Self::Attestation),
116            "StaleData" => Some(Self::StaleData),
117            _ => None,
118        }
119    }
120
121    #[must_use]
122    pub fn label(self) -> &'static str {
123        match self {
124            Self::Signature => "Signature",
125            Self::Transparency => "Transparency",
126            Self::Attestation => "Attestation",
127            Self::StaleData => "StaleData",
128        }
129    }
130}
131
132/// Optional posterior snapshot the receipt binds (from the input JSON).
133#[derive(Debug, Clone, PartialEq, Default)]
134pub struct PosteriorSnapshot {
135    pub point_estimate: Option<f64>,
136    pub ci_lower: Option<f64>,
137    pub ci_upper: Option<f64>,
138}
139
140/// A parsed verifier verdict ready for the panel to render.
141///
142/// Maps onto the `UnifiedReceiptVerificationVerdict` shape that
143/// `frankenctl verify receipt --output` emits.
144#[derive(Debug, Clone, PartialEq)]
145pub struct ReceiptVerdict {
146    pub receipt_id: String,
147    pub trace_id: String,
148    pub decision_id: String,
149    pub policy_id: String,
150    pub verification_timestamp_ns: u64,
151    pub passed: bool,
152    pub failure_class: Option<FailureClass>,
153    pub exit_code: i32,
154    pub signature: LayerVerdict,
155    pub transparency: LayerVerdict,
156    pub attestation: LayerVerdict,
157    pub warnings: Vec<String>,
158    /// Optional — populated from the verifier input JSON when available.
159    pub posterior_snapshot: Option<PosteriorSnapshot>,
160}
161
162impl ReceiptVerdict {
163    /// Build a minimal verdict skeleton (mostly for tests / previews).
164    #[must_use]
165    pub fn skeleton(receipt_id: impl Into<String>, passed: bool) -> Self {
166        Self {
167            receipt_id: receipt_id.into(),
168            trace_id: String::new(),
169            decision_id: String::new(),
170            policy_id: String::new(),
171            verification_timestamp_ns: 0,
172            passed,
173            failure_class: None,
174            exit_code: if passed { 0 } else { 2 },
175            signature: LayerVerdict::default(),
176            transparency: LayerVerdict::default(),
177            attestation: LayerVerdict::default(),
178            warnings: Vec::new(),
179            posterior_snapshot: None,
180        }
181    }
182}
183
184// ---------------------------------------------------------------------------
185// Panel widget
186// ---------------------------------------------------------------------------
187
188/// Receipt verifier panel — renders a [`ReceiptVerdict`] inside a bordered
189/// frame.
190///
191/// # Usage
192///
193/// ```ignore
194/// use ftui_widgets::receipt_verifier_panel::{ReceiptVerifierPanel, ReceiptVerdict};
195///
196/// let verdict = ReceiptVerdict::skeleton("rcpt-001", true);
197/// let panel = ReceiptVerifierPanel::new(&verdict)
198///     .show_posterior_path(true)
199///     .show_evidence_chain(true);
200/// panel.render(area, &mut frame);
201/// ```
202#[derive(Debug, Clone)]
203pub struct ReceiptVerifierPanel<'a> {
204    verdict: &'a ReceiptVerdict,
205    border_type: BorderType,
206    style: Style,
207    title_style: Style,
208    show_posterior_path: bool,
209    show_evidence_chain: bool,
210    show_triage: bool,
211}
212
213impl<'a> ReceiptVerifierPanel<'a> {
214    /// Create a new panel for the given verdict.
215    ///
216    /// Defaults: rounded border, posterior path and evidence chain hidden,
217    /// triage block shown on failure.
218    #[must_use]
219    pub fn new(verdict: &'a ReceiptVerdict) -> Self {
220        Self {
221            verdict,
222            border_type: BorderType::Rounded,
223            style: Style::default(),
224            title_style: Style::default().bold(),
225            show_posterior_path: false,
226            show_evidence_chain: false,
227            show_triage: true,
228        }
229    }
230
231    #[must_use]
232    pub fn border_type(mut self, border_type: BorderType) -> Self {
233        self.border_type = border_type;
234        self
235    }
236
237    #[must_use]
238    pub fn style(mut self, style: Style) -> Self {
239        self.style = style;
240        self
241    }
242
243    #[must_use]
244    pub fn title_style(mut self, style: Style) -> Self {
245        self.title_style = style;
246        self
247    }
248
249    /// Show the decision posterior snapshot the receipt binds, when available
250    /// in [`ReceiptVerdict::posterior_snapshot`].
251    #[must_use]
252    pub fn show_posterior_path(mut self, show: bool) -> Self {
253        self.show_posterior_path = show;
254        self
255    }
256
257    /// Show the transparency-log + signature evidence-chain checks.
258    #[must_use]
259    pub fn show_evidence_chain(mut self, show: bool) -> Self {
260        self.show_evidence_chain = show;
261        self
262    }
263
264    /// Show the failure-class triage block on failure. On by default.
265    #[must_use]
266    pub fn show_triage(mut self, show: bool) -> Self {
267        self.show_triage = show;
268        self
269    }
270
271    /// Minimum height needed to render the panel given the current options.
272    #[must_use]
273    pub fn min_height(&self) -> u16 {
274        // Border top + verdict row + provenance row + 3 layer rows + border bottom = 7.
275        let mut h: u16 = 7;
276        if self.show_posterior_path
277            && let Some(snapshot) = self.verdict.posterior_snapshot.as_ref()
278        {
279            // header + up to 3 numeric rows
280            h += 1 + posterior_visible_rows(snapshot);
281        }
282        if self.show_evidence_chain {
283            // header + per-layer line + each check line for signature+transparency
284            h += 1 + 2; // headers for transparency + signature blocks
285            h += self.verdict.transparency.checks.len() as u16;
286            h += self.verdict.signature.checks.len() as u16;
287        }
288        if !self.verdict.warnings.is_empty() {
289            h += 1 + self.verdict.warnings.len() as u16; // header + per-warning
290        }
291        if self.show_triage
292            && let Some(fc) = self.verdict.failure_class
293        {
294            h += triage_height(fc);
295        }
296        h
297    }
298
299    fn border_style_for_status(passed: bool) -> Style {
300        if passed {
301            Style::new().fg(PASS_FG)
302        } else {
303            Style::new().fg(FAIL_FG)
304        }
305    }
306
307    fn render_border(&self, area: Rect, frame: &mut Frame, border_style: Style) {
308        let deg = frame.buffer.degradation;
309        let set = if deg.use_unicode_borders() {
310            self.border_type.to_border_set()
311        } else {
312            BorderSet::ASCII
313        };
314
315        let border_cell = |c: char| -> Cell {
316            let mut cell = Cell::from_char(c);
317            apply_style(&mut cell, border_style);
318            cell
319        };
320
321        for x in area.x..area.right() {
322            frame
323                .buffer
324                .set_fast(x, area.y, border_cell(set.horizontal));
325        }
326        let bottom_y = area.bottom().saturating_sub(1);
327        for x in area.x..area.right() {
328            frame
329                .buffer
330                .set_fast(x, bottom_y, border_cell(set.horizontal));
331        }
332        for y in area.y..area.bottom() {
333            frame.buffer.set_fast(area.x, y, border_cell(set.vertical));
334        }
335        let right_x = area.right().saturating_sub(1);
336        for y in area.y..area.bottom() {
337            frame.buffer.set_fast(right_x, y, border_cell(set.vertical));
338        }
339        frame
340            .buffer
341            .set_fast(area.x, area.y, border_cell(set.top_left));
342        frame
343            .buffer
344            .set_fast(right_x, area.y, border_cell(set.top_right));
345        frame
346            .buffer
347            .set_fast(area.x, bottom_y, border_cell(set.bottom_left));
348        frame
349            .buffer
350            .set_fast(right_x, bottom_y, border_cell(set.bottom_right));
351    }
352
353    fn verdict_badge_style(passed: bool, apply_styling: bool) -> Style {
354        if !apply_styling {
355            return Style::default();
356        }
357        if passed {
358            Style::new().fg(PASS_FG).bg(PASS_BG).bold()
359        } else {
360            Style::new().fg(FAIL_FG).bg(FAIL_BG).bold()
361        }
362    }
363
364    fn render_verdict_row(&self, x: u16, y: u16, max_x: u16, frame: &mut Frame) {
365        let apply_styling = frame.buffer.degradation.apply_styling();
366        let badge_text = if self.verdict.passed {
367            " VERIFIED "
368        } else {
369            " FAILED "
370        };
371        let badge_style = Self::verdict_badge_style(self.verdict.passed, apply_styling);
372        let title_style = if apply_styling {
373            self.title_style
374        } else {
375            Style::default()
376        };
377
378        let mut cx = draw_text_span(frame, x, y, badge_text, badge_style, max_x);
379        if cx < max_x {
380            cx = draw_text_span(frame, cx, y, " ", Style::default(), max_x);
381        }
382        let id_text = format!("receipt {}", self.verdict.receipt_id);
383        cx = draw_text_span(frame, cx, y, &id_text, title_style, max_x);
384
385        if !self.verdict.passed
386            && let Some(fc) = self.verdict.failure_class
387        {
388            let detail_style = if apply_styling {
389                Style::new().fg(FAIL_FG)
390            } else {
391                Style::default()
392            };
393            let trail = format!(
394                "   failure_class={}  exit_code={}",
395                fc.label(),
396                self.verdict.exit_code
397            );
398            let _ = draw_text_span(frame, cx, y, &trail, detail_style, max_x);
399        }
400    }
401
402    fn render_provenance_row(&self, x: u16, y: u16, max_x: u16, frame: &mut Frame) {
403        let style = if frame.buffer.degradation.apply_styling() {
404            Style::new().fg(DETAIL_FG)
405        } else {
406            Style::default()
407        };
408        let line = format!(
409            "trace={}  decision={}  policy={}",
410            display_or_dash(&self.verdict.trace_id),
411            display_or_dash(&self.verdict.decision_id),
412            display_or_dash(&self.verdict.policy_id),
413        );
414        draw_text_span(frame, x, y, &line, style, max_x);
415    }
416
417    fn render_layer_row(
418        x: u16,
419        y: u16,
420        max_x: u16,
421        frame: &mut Frame,
422        name: &str,
423        layer: &LayerVerdict,
424    ) {
425        let apply_styling = frame.buffer.degradation.apply_styling();
426        let label = format!("  {name:<13} ");
427        let label_style = if apply_styling {
428            Style::new().fg(HEADER_FG)
429        } else {
430            Style::default()
431        };
432        let mut cx = draw_text_span(frame, x, y, &label, label_style, max_x);
433
434        let outcome_label = if layer.passed { "PASS" } else { "FAIL" };
435        let outcome_style = if !apply_styling {
436            Style::default()
437        } else if layer.passed {
438            Style::new().fg(PASS_FG).bold()
439        } else {
440            Style::new().fg(FAIL_FG).bold()
441        };
442        cx = draw_text_span(frame, cx, y, outcome_label, outcome_style, max_x);
443
444        let trail_style = if apply_styling {
445            Style::new().fg(DIM_FG)
446        } else {
447            Style::default()
448        };
449        let trail = format!(
450            "   error_code={}",
451            layer.error_code.as_deref().unwrap_or("-")
452        );
453        let _ = draw_text_span(frame, cx, y, &trail, trail_style, max_x);
454    }
455
456    fn render_posterior_path(&self, x: u16, mut y: u16, max_x: u16, frame: &mut Frame) -> u16 {
457        let Some(snapshot) = self.verdict.posterior_snapshot.as_ref() else {
458            return y;
459        };
460        let apply_styling = frame.buffer.degradation.apply_styling();
461        let header_style = if apply_styling {
462            Style::new().fg(HEADER_FG).bold()
463        } else {
464            Style::default()
465        };
466        let detail_style = if apply_styling {
467            Style::new().fg(DETAIL_FG)
468        } else {
469            Style::default()
470        };
471
472        draw_text_span(frame, x, y, "posterior path:", header_style, max_x);
473        y += 1;
474        if let Some(p) = snapshot.point_estimate {
475            draw_text_span(
476                frame,
477                x,
478                y,
479                &format!("  point_estimate = {p}"),
480                detail_style,
481                max_x,
482            );
483            y += 1;
484        }
485        if let Some(lo) = snapshot.ci_lower {
486            draw_text_span(
487                frame,
488                x,
489                y,
490                &format!("  confidence_interval_95_lower = {lo}"),
491                detail_style,
492                max_x,
493            );
494            y += 1;
495        }
496        if let Some(hi) = snapshot.ci_upper {
497            draw_text_span(
498                frame,
499                x,
500                y,
501                &format!("  confidence_interval_95_upper = {hi}"),
502                detail_style,
503                max_x,
504            );
505            y += 1;
506        }
507        y
508    }
509
510    fn render_evidence_chain(&self, x: u16, mut y: u16, max_x: u16, frame: &mut Frame) -> u16 {
511        let apply_styling = frame.buffer.degradation.apply_styling();
512        let header_style = if apply_styling {
513            Style::new().fg(HEADER_FG).bold()
514        } else {
515            Style::default()
516        };
517
518        draw_text_span(frame, x, y, "evidence chain:", header_style, max_x);
519        y += 1;
520        y = render_layer_checks(
521            x,
522            y,
523            max_x,
524            frame,
525            "transparency",
526            &self.verdict.transparency,
527        );
528        y = render_layer_checks(x, y, max_x, frame, "signature", &self.verdict.signature);
529        y
530    }
531
532    fn render_warnings(&self, x: u16, mut y: u16, max_x: u16, frame: &mut Frame) -> u16 {
533        if self.verdict.warnings.is_empty() {
534            return y;
535        }
536        let apply_styling = frame.buffer.degradation.apply_styling();
537        let header_style = if apply_styling {
538            Style::new().fg(WARN_FG).bold()
539        } else {
540            Style::default()
541        };
542        let line_style = if apply_styling {
543            Style::new().fg(WARN_FG)
544        } else {
545            Style::default()
546        };
547        draw_text_span(frame, x, y, "warnings:", header_style, max_x);
548        y += 1;
549        for w in &self.verdict.warnings {
550            draw_text_span(frame, x, y, &format!("  ! {w}"), line_style, max_x);
551            y += 1;
552        }
553        y
554    }
555
556    fn render_triage(&self, x: u16, mut y: u16, max_x: u16, frame: &mut Frame) -> u16 {
557        if !self.show_triage {
558            return y;
559        }
560        let Some(fc) = self.verdict.failure_class else {
561            return y;
562        };
563        let apply_styling = frame.buffer.degradation.apply_styling();
564        let header_style = if apply_styling {
565            Style::new().fg(FAIL_FG).bold()
566        } else {
567            Style::default()
568        };
569        let body_style = if apply_styling {
570            Style::new().fg(DETAIL_FG)
571        } else {
572            Style::default()
573        };
574
575        draw_text_span(
576            frame,
577            x,
578            y,
579            &format!("incident triage (failure_class={}):", fc.label()),
580            header_style,
581            max_x,
582        );
583        y += 1;
584        for line in triage_lines(fc) {
585            draw_text_span(frame, x, y, line, body_style, max_x);
586            y += 1;
587        }
588        y
589    }
590}
591
592impl Widget for ReceiptVerifierPanel<'_> {
593    fn render(&self, area: Rect, frame: &mut Frame) {
594        if area.is_empty() {
595            return;
596        }
597        if area.width < 4 || area.height < 3 {
598            clear_text_area(frame, area, Style::default());
599            return;
600        }
601
602        let deg = frame.buffer.degradation;
603        if !deg.render_content() {
604            clear_text_area(frame, area, Style::default());
605            return;
606        }
607
608        let base_style = if deg.apply_styling() {
609            self.style
610        } else {
611            Style::default()
612        };
613        clear_text_area(frame, area, base_style);
614
615        let border_style = Self::border_style_for_status(self.verdict.passed);
616        let border_style = if deg.apply_styling() {
617            border_style
618        } else {
619            Style::default()
620        };
621        if deg.render_decorative() {
622            self.render_border(area, frame, border_style);
623        }
624
625        let inner_x = area.x.saturating_add(1);
626        let inner_max_x = area.right().saturating_sub(1);
627        let mut y = area.y.saturating_add(1);
628        let max_y = area.bottom().saturating_sub(1);
629
630        if y >= max_y || inner_x >= inner_max_x {
631            return;
632        }
633
634        // 1. Verdict row.
635        self.render_verdict_row(inner_x, y, inner_max_x, frame);
636        y += 1;
637        if y >= max_y {
638            return;
639        }
640
641        // 2. Provenance row.
642        self.render_provenance_row(inner_x, y, inner_max_x, frame);
643        y += 1;
644        if y >= max_y {
645            return;
646        }
647
648        // 3. Three-layer status block.
649        for (name, layer) in [
650            ("signature", &self.verdict.signature),
651            ("transparency", &self.verdict.transparency),
652            ("attestation", &self.verdict.attestation),
653        ] {
654            if y >= max_y {
655                return;
656            }
657            Self::render_layer_row(inner_x, y, inner_max_x, frame, name, layer);
658            y += 1;
659        }
660
661        // 4. Optional posterior path.
662        if self.show_posterior_path && self.verdict.posterior_snapshot.is_some() && y < max_y {
663            y = self.render_posterior_path(inner_x, y, inner_max_x, frame);
664        }
665
666        // 5. Optional evidence chain.
667        if self.show_evidence_chain && y < max_y {
668            y = self.render_evidence_chain(inner_x, y, inner_max_x, frame);
669        }
670
671        // 6. Warnings.
672        if y < max_y {
673            y = self.render_warnings(inner_x, y, inner_max_x, frame);
674        }
675
676        // 7. Triage.
677        if y < max_y {
678            let _ = self.render_triage(inner_x, y, inner_max_x, frame);
679        }
680    }
681
682    fn is_essential(&self) -> bool {
683        false
684    }
685}
686
687// ---------------------------------------------------------------------------
688// Helpers
689// ---------------------------------------------------------------------------
690
691fn display_or_dash(s: &str) -> &str {
692    if s.is_empty() { "-" } else { s }
693}
694
695fn render_layer_checks(
696    x: u16,
697    mut y: u16,
698    max_x: u16,
699    frame: &mut Frame,
700    name: &str,
701    layer: &LayerVerdict,
702) -> u16 {
703    let apply_styling = frame.buffer.degradation.apply_styling();
704    let header_style = if apply_styling {
705        Style::new().fg(HEADER_FG)
706    } else {
707        Style::default()
708    };
709    let verdict = if layer.passed { "PASS" } else { "FAIL" };
710    draw_text_span(
711        frame,
712        x,
713        y,
714        &format!("  {name} layer ({verdict}):"),
715        header_style,
716        max_x,
717    );
718    y += 1;
719    for check in &layer.checks {
720        let outcome_style = if !apply_styling {
721            Style::default()
722        } else {
723            match check.outcome {
724                CheckOutcome::Pass => Style::new().fg(PASS_FG),
725                CheckOutcome::Fail => Style::new().fg(FAIL_FG),
726                CheckOutcome::Warn => Style::new().fg(WARN_FG),
727            }
728        };
729        let line = format!(
730            "    [{}] {} — {}",
731            check.outcome.label(),
732            check.check,
733            check.detail
734        );
735        draw_text_span(frame, x, y, &line, outcome_style, max_x);
736        y += 1;
737    }
738    y
739}
740
741fn posterior_visible_rows(snapshot: &PosteriorSnapshot) -> u16 {
742    let mut n: u16 = 0;
743    if snapshot.point_estimate.is_some() {
744        n += 1;
745    }
746    if snapshot.ci_lower.is_some() {
747        n += 1;
748    }
749    if snapshot.ci_upper.is_some() {
750        n += 1;
751    }
752    n
753}
754
755/// Operator-facing triage lines per failure class. Mirrors the prose emitted
756/// by `verify_receipt.sh print_triage`.
757fn triage_lines(fc: FailureClass) -> &'static [&'static str] {
758    match fc {
759        FailureClass::Signature => &[
760            "  The receipt's threshold signature did not validate.",
761            "  -> Confirm the verifier has the correct signer verification keys.",
762            "  -> A genuine mismatch means the receipt was not produced by the",
763            "     attested signing quorum: treat the decision as UNTRUSTED.",
764        ],
765        FailureClass::Transparency => &[
766            "  The transparency-log inclusion and/or consistency proof failed.",
767            "  -> --show-evidence-chain shows which MMR proof check failed.",
768            "  -> Inclusion fail: the receipt is not in the published log",
769            "     (possible equivocation/omission). Consistency fail: the log was",
770            "     forked or rewritten between checkpoints. Escalate to log-operator.",
771        ],
772        FailureClass::Attestation => &[
773            "  The TEE attestation quote could not be validated.",
774            "  -> Runtime has degraded to SAFE-MODE: it continues only under the",
775            "     restricted capability posture (no attested-only operations),",
776            "     because it can no longer prove it runs in a trusted enclave.",
777            "  -> Check tee_attestation_policy freshness/measurement; a stale or",
778            "     mismatched measurement is the usual cause. Re-attest before",
779            "     promoting any decision that requires a trusted enclave.",
780        ],
781        FailureClass::StaleData => &[
782            "  Verifier input is stale (timestamp/epoch outside accepted window).",
783            "  -> Re-export a fresh verifier_input.json for this receipt and re-run.",
784        ],
785    }
786}
787
788fn triage_height(fc: FailureClass) -> u16 {
789    1 + triage_lines(fc).len() as u16
790}
791
792// ===========================================================================
793// Tests
794// ===========================================================================
795
796#[cfg(test)]
797mod tests {
798    use super::*;
799    use ftui_render::frame::Frame;
800    use ftui_render::grapheme_pool::GraphemePool;
801
802    fn make_pass() -> ReceiptVerdict {
803        let mut v = ReceiptVerdict::skeleton("rcpt-001", true);
804        v.trace_id = "trace-aaa".into();
805        v.decision_id = "dec-bbb".into();
806        v.policy_id = "pol-ccc".into();
807        v.signature = LayerVerdict {
808            passed: true,
809            error_code: None,
810            checks: vec![CheckEntry {
811                check: "threshold_signature".into(),
812                outcome: CheckOutcome::Pass,
813                error_code: None,
814                detail: "3/3 signers".into(),
815            }],
816        };
817        v.transparency = LayerVerdict {
818            passed: true,
819            error_code: None,
820            checks: vec![
821                CheckEntry {
822                    check: "mmr_inclusion".into(),
823                    outcome: CheckOutcome::Pass,
824                    error_code: None,
825                    detail: "leaf 41 under root r9".into(),
826                },
827                CheckEntry {
828                    check: "mmr_consistency".into(),
829                    outcome: CheckOutcome::Pass,
830                    error_code: None,
831                    detail: "c0->c1 consistent".into(),
832                },
833            ],
834        };
835        v.attestation = LayerVerdict::default();
836        v.attestation.passed = true;
837        v.posterior_snapshot = Some(PosteriorSnapshot {
838            point_estimate: Some(0.873),
839            ci_lower: Some(0.81),
840            ci_upper: Some(0.92),
841        });
842        v
843    }
844
845    fn make_attestation_fail() -> ReceiptVerdict {
846        let mut v = ReceiptVerdict::skeleton("rcpt-001", false);
847        v.trace_id = "trace-aaa".into();
848        v.decision_id = "dec-bbb".into();
849        v.policy_id = "pol-ccc".into();
850        v.failure_class = Some(FailureClass::Attestation);
851        v.exit_code = 2;
852        v.signature = LayerVerdict {
853            passed: true,
854            error_code: None,
855            checks: vec![],
856        };
857        v.transparency = LayerVerdict {
858            passed: true,
859            error_code: None,
860            checks: vec![],
861        };
862        v.attestation = LayerVerdict {
863            passed: false,
864            error_code: Some("ATTEST_STALE".into()),
865            checks: vec![CheckEntry {
866                check: "quote_freshness".into(),
867                outcome: CheckOutcome::Fail,
868                error_code: Some("ATTEST_STALE".into()),
869                detail: "quote older than max age".into(),
870            }],
871        };
872        v.warnings = vec!["attestation degraded; running in safe-mode".into()];
873        v
874    }
875
876    fn extract_row(frame: &Frame, y: u16, width: u16) -> String {
877        let mut row = String::new();
878        for x in 0..width {
879            if let Some(cell) = frame.buffer.get(x, y)
880                && let Some(ch) = cell.content.as_char()
881            {
882                row.push(ch);
883            } else {
884                row.push(' ');
885            }
886        }
887        row
888    }
889
890    fn frame_has_text(frame: &Frame, width: u16, height: u16, needle: &str) -> bool {
891        for y in 0..height {
892            if extract_row(frame, y, width).contains(needle) {
893                return true;
894            }
895        }
896        false
897    }
898
899    #[test]
900    fn check_outcome_parse_round_trip() {
901        assert_eq!(CheckOutcome::parse("pass"), CheckOutcome::Pass);
902        assert_eq!(CheckOutcome::parse("PASS"), CheckOutcome::Pass);
903        assert_eq!(CheckOutcome::parse("fail"), CheckOutcome::Fail);
904        assert_eq!(CheckOutcome::parse("FAIL"), CheckOutcome::Fail);
905        assert_eq!(CheckOutcome::parse("anything-else"), CheckOutcome::Warn);
906        assert_eq!(CheckOutcome::Pass.label(), "PASS");
907        assert_eq!(CheckOutcome::Fail.label(), "FAIL");
908        assert_eq!(CheckOutcome::Warn.label(), "WARN");
909    }
910
911    #[test]
912    fn failure_class_parse_maps_all_known() {
913        assert_eq!(
914            FailureClass::parse("Signature"),
915            Some(FailureClass::Signature)
916        );
917        assert_eq!(
918            FailureClass::parse("Transparency"),
919            Some(FailureClass::Transparency)
920        );
921        assert_eq!(
922            FailureClass::parse("Attestation"),
923            Some(FailureClass::Attestation)
924        );
925        assert_eq!(
926            FailureClass::parse("StaleData"),
927            Some(FailureClass::StaleData)
928        );
929        assert_eq!(FailureClass::parse("null"), None);
930        assert_eq!(FailureClass::parse(""), None);
931    }
932
933    #[test]
934    fn pass_verdict_renders_verified_and_provenance() {
935        let v = make_pass();
936        let mut pool = GraphemePool::new();
937        let mut frame = Frame::new(80, 12, &mut pool);
938        ReceiptVerifierPanel::new(&v).render(Rect::new(0, 0, 80, 12), &mut frame);
939        assert!(frame_has_text(&frame, 80, 12, "VERIFIED"));
940        assert!(frame_has_text(&frame, 80, 12, "receipt rcpt-001"));
941        assert!(frame_has_text(&frame, 80, 12, "trace=trace-aaa"));
942        assert!(frame_has_text(&frame, 80, 12, "signature"));
943        assert!(frame_has_text(&frame, 80, 12, "transparency"));
944        assert!(frame_has_text(&frame, 80, 12, "attestation"));
945    }
946
947    #[test]
948    fn fail_verdict_surfaces_failure_class_in_verdict_row() {
949        let v = make_attestation_fail();
950        let mut pool = GraphemePool::new();
951        let mut frame = Frame::new(80, 18, &mut pool);
952        ReceiptVerifierPanel::new(&v).render(Rect::new(0, 0, 80, 18), &mut frame);
953        assert!(frame_has_text(&frame, 80, 18, "FAILED"));
954        assert!(frame_has_text(&frame, 80, 18, "failure_class=Attestation"));
955        // attestation layer surfaces its error_code on the layer row.
956        assert!(frame_has_text(&frame, 80, 18, "error_code=ATTEST_STALE"));
957    }
958
959    #[test]
960    fn show_posterior_path_renders_point_estimate() {
961        let v = make_pass();
962        let mut pool = GraphemePool::new();
963        let mut frame = Frame::new(80, 18, &mut pool);
964        ReceiptVerifierPanel::new(&v)
965            .show_posterior_path(true)
966            .render(Rect::new(0, 0, 80, 18), &mut frame);
967        assert!(frame_has_text(&frame, 80, 18, "posterior path:"));
968        assert!(frame_has_text(&frame, 80, 18, "point_estimate = 0.873"));
969        assert!(frame_has_text(
970            &frame,
971            80,
972            18,
973            "confidence_interval_95_lower = 0.81"
974        ));
975    }
976
977    #[test]
978    fn show_evidence_chain_renders_layer_checks() {
979        let v = make_pass();
980        let mut pool = GraphemePool::new();
981        let mut frame = Frame::new(80, 18, &mut pool);
982        ReceiptVerifierPanel::new(&v)
983            .show_evidence_chain(true)
984            .render(Rect::new(0, 0, 80, 18), &mut frame);
985        assert!(frame_has_text(&frame, 80, 18, "evidence chain:"));
986        assert!(frame_has_text(&frame, 80, 18, "mmr_inclusion"));
987        assert!(frame_has_text(&frame, 80, 18, "mmr_consistency"));
988        assert!(frame_has_text(&frame, 80, 18, "threshold_signature"));
989    }
990
991    #[test]
992    fn attestation_failure_triage_mentions_safe_mode() {
993        let v = make_attestation_fail();
994        let mut pool = GraphemePool::new();
995        let mut frame = Frame::new(80, 24, &mut pool);
996        ReceiptVerifierPanel::new(&v).render(Rect::new(0, 0, 80, 24), &mut frame);
997        assert!(frame_has_text(&frame, 80, 24, "incident triage"));
998        assert!(frame_has_text(&frame, 80, 24, "SAFE-MODE"));
999    }
1000
1001    #[test]
1002    fn warnings_block_renders_when_present() {
1003        let v = make_attestation_fail();
1004        let mut pool = GraphemePool::new();
1005        let mut frame = Frame::new(80, 24, &mut pool);
1006        ReceiptVerifierPanel::new(&v).render(Rect::new(0, 0, 80, 24), &mut frame);
1007        assert!(frame_has_text(&frame, 80, 24, "warnings:"));
1008        assert!(frame_has_text(&frame, 80, 24, "running in safe-mode"));
1009    }
1010
1011    #[test]
1012    fn empty_receipt_id_still_renders_provenance_dash() {
1013        let v = ReceiptVerdict::skeleton("rcpt-empty", true);
1014        let mut pool = GraphemePool::new();
1015        let mut frame = Frame::new(80, 10, &mut pool);
1016        ReceiptVerifierPanel::new(&v).render(Rect::new(0, 0, 80, 10), &mut frame);
1017        // Empty trace/decision/policy → rendered as `-`.
1018        assert!(frame_has_text(&frame, 80, 10, "trace=-"));
1019        assert!(frame_has_text(&frame, 80, 10, "decision=-"));
1020        assert!(frame_has_text(&frame, 80, 10, "policy=-"));
1021    }
1022
1023    #[test]
1024    fn tiny_area_does_not_panic() {
1025        let v = make_pass();
1026        let mut pool = GraphemePool::new();
1027        let mut frame = Frame::new(3, 2, &mut pool);
1028        ReceiptVerifierPanel::new(&v).render(Rect::new(0, 0, 3, 2), &mut frame);
1029        let mut frame = Frame::new(1, 1, &mut pool);
1030        ReceiptVerifierPanel::new(&v).render(Rect::new(0, 0, 1, 1), &mut frame);
1031    }
1032
1033    #[test]
1034    fn min_height_grows_with_options() {
1035        let v = make_pass();
1036        let base = ReceiptVerifierPanel::new(&v).min_height();
1037        let with_post = ReceiptVerifierPanel::new(&v)
1038            .show_posterior_path(true)
1039            .min_height();
1040        assert!(
1041            with_post > base,
1042            "posterior path should increase min_height"
1043        );
1044        let with_chain = ReceiptVerifierPanel::new(&v)
1045            .show_evidence_chain(true)
1046            .min_height();
1047        assert!(
1048            with_chain > base,
1049            "evidence chain should increase min_height"
1050        );
1051    }
1052
1053    /// Selftest: the canonical verifier output JSON (lifted verbatim from
1054    /// `verify_receipt.sh selftest`) deserialises cleanly into [`ReceiptVerdict`]
1055    /// when the caller plumbs a serde_json pass through the data model.
1056    #[test]
1057    fn canonical_verifier_pass_json_round_trips_through_data_model() {
1058        let pass_json = r#"{
1059            "receipt_id":"rcpt-001","trace_id":"trace-aaa","decision_id":"dec-bbb",
1060            "policy_id":"pol-ccc","verification_timestamp_ns":1716000000000000000,
1061            "passed":true,"failure_class":null,"exit_code":0,
1062            "signature":{"passed":true,"error_code":null,"checks":[
1063                {"check":"threshold_signature","outcome":"pass","error_code":null,"detail":"3/3 signers"}
1064            ]},
1065            "transparency":{"passed":true,"error_code":null,"checks":[
1066                {"check":"mmr_inclusion","outcome":"pass","error_code":null,"detail":"leaf 41 under root r9"},
1067                {"check":"mmr_consistency","outcome":"pass","error_code":null,"detail":"c0->c1 consistent"}
1068            ]},
1069            "attestation":{"passed":true,"error_code":null,"checks":[]},
1070            "warnings":[],"logs":[]
1071        }"#;
1072        let v: serde_json::Value = serde_json::from_str(pass_json).unwrap();
1073        // Hand-marshal the JSON value into our data model — this also
1074        // doubles as the canonical reference adapter for callers.
1075        let verdict = verdict_from_json(&v).expect("canonical JSON should parse");
1076        assert_eq!(verdict.receipt_id, "rcpt-001");
1077        assert!(verdict.passed);
1078        assert_eq!(verdict.failure_class, None);
1079        assert_eq!(verdict.signature.checks.len(), 1);
1080        assert_eq!(verdict.transparency.checks.len(), 2);
1081        assert_eq!(verdict.transparency.checks[1].check, "mmr_consistency");
1082    }
1083
1084    /// Test-only adapter from the verifier's JSON Value into [`ReceiptVerdict`].
1085    /// Kept in tests (not the public API) so the widget stays serde-free; this
1086    /// also doubles as a worked example for downstream callers.
1087    fn verdict_from_json(v: &serde_json::Value) -> Option<ReceiptVerdict> {
1088        fn layer(v: &serde_json::Value) -> LayerVerdict {
1089            let passed = v.get("passed").and_then(|x| x.as_bool()).unwrap_or(false);
1090            let error_code = v
1091                .get("error_code")
1092                .and_then(|x| x.as_str())
1093                .map(String::from);
1094            let checks = v
1095                .get("checks")
1096                .and_then(|x| x.as_array())
1097                .map(|arr| {
1098                    arr.iter()
1099                        .map(|c| CheckEntry {
1100                            check: c
1101                                .get("check")
1102                                .and_then(|x| x.as_str())
1103                                .unwrap_or("")
1104                                .to_string(),
1105                            outcome: CheckOutcome::parse(
1106                                c.get("outcome").and_then(|x| x.as_str()).unwrap_or(""),
1107                            ),
1108                            error_code: c
1109                                .get("error_code")
1110                                .and_then(|x| x.as_str())
1111                                .map(String::from),
1112                            detail: c
1113                                .get("detail")
1114                                .and_then(|x| x.as_str())
1115                                .unwrap_or("")
1116                                .to_string(),
1117                        })
1118                        .collect()
1119                })
1120                .unwrap_or_default();
1121            LayerVerdict {
1122                passed,
1123                error_code,
1124                checks,
1125            }
1126        }
1127        Some(ReceiptVerdict {
1128            receipt_id: v.get("receipt_id")?.as_str()?.to_string(),
1129            trace_id: v
1130                .get("trace_id")
1131                .and_then(|x| x.as_str())
1132                .unwrap_or("")
1133                .to_string(),
1134            decision_id: v
1135                .get("decision_id")
1136                .and_then(|x| x.as_str())
1137                .unwrap_or("")
1138                .to_string(),
1139            policy_id: v
1140                .get("policy_id")
1141                .and_then(|x| x.as_str())
1142                .unwrap_or("")
1143                .to_string(),
1144            verification_timestamp_ns: v
1145                .get("verification_timestamp_ns")
1146                .and_then(|x| x.as_u64())
1147                .unwrap_or(0),
1148            passed: v.get("passed").and_then(|x| x.as_bool()).unwrap_or(false),
1149            failure_class: v
1150                .get("failure_class")
1151                .and_then(|x| x.as_str())
1152                .and_then(FailureClass::parse),
1153            exit_code: v.get("exit_code").and_then(|x| x.as_i64()).unwrap_or(0) as i32,
1154            signature: v.get("signature").map(layer).unwrap_or_default(),
1155            transparency: v.get("transparency").map(layer).unwrap_or_default(),
1156            attestation: v.get("attestation").map(layer).unwrap_or_default(),
1157            warnings: v
1158                .get("warnings")
1159                .and_then(|x| x.as_array())
1160                .map(|arr| {
1161                    arr.iter()
1162                        .filter_map(|w| w.as_str().map(String::from))
1163                        .collect()
1164                })
1165                .unwrap_or_default(),
1166            posterior_snapshot: None,
1167        })
1168    }
1169}