1pub const RECEIPT_SIZE: usize = 72;
49
50pub const RECEIPT_SIZE_LEGACY: usize = 64;
53
54pub const FAILED_INVARIANT_NONE: u8 = 0xFF;
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum ReceiptError {
61 TooShort {
63 got: usize,
65 },
66 ReservedNonZero,
68 InvalidPhase(u8),
70 InvalidCompatImpact(u8),
72 InvalidFailureStage(u8),
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81#[repr(u8)]
82pub enum Phase {
83 Update = 0,
85 Init = 1,
87 Close = 2,
89 Migrate = 3,
91 ReadOnly = 4,
93}
94
95impl Phase {
96 fn from_u8(v: u8) -> Option<Self> {
97 Some(match v {
98 0 => Phase::Update,
99 1 => Phase::Init,
100 2 => Phase::Close,
101 3 => Phase::Migrate,
102 4 => Phase::ReadOnly,
103 _ => return None,
104 })
105 }
106
107 pub const fn name(self) -> &'static str {
109 match self {
110 Phase::Update => "update",
111 Phase::Init => "init",
112 Phase::Close => "close",
113 Phase::Migrate => "migrate",
114 Phase::ReadOnly => "readonly",
115 }
116 }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122#[repr(u8)]
123pub enum CompatImpact {
124 None = 0,
126 Append = 1,
128 Migration = 2,
130 Breaking = 3,
132}
133
134impl CompatImpact {
135 fn from_u8(v: u8) -> Option<Self> {
136 Some(match v {
137 0 => CompatImpact::None,
138 1 => CompatImpact::Append,
139 2 => CompatImpact::Migration,
140 3 => CompatImpact::Breaking,
141 _ => return None,
142 })
143 }
144
145 pub const fn name(self) -> &'static str {
147 match self {
148 CompatImpact::None => "none",
149 CompatImpact::Append => "append",
150 CompatImpact::Migration => "migration",
151 CompatImpact::Breaking => "breaking",
152 }
153 }
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160#[repr(u8)]
161pub enum FailureStage {
162 None = 0,
164 Validation = 1,
166 Handler = 2,
168 Invariant = 3,
170 Post = 4,
172 Teardown = 5,
174}
175
176impl FailureStage {
177 fn from_u8(v: u8) -> Option<Self> {
178 Some(match v {
179 0 => FailureStage::None,
180 1 => FailureStage::Validation,
181 2 => FailureStage::Handler,
182 3 => FailureStage::Invariant,
183 4 => FailureStage::Post,
184 5 => FailureStage::Teardown,
185 _ => return None,
186 })
187 }
188
189 pub const fn name(self) -> &'static str {
191 match self {
192 FailureStage::None => "none",
193 FailureStage::Validation => "validation",
194 FailureStage::Handler => "handler",
195 FailureStage::Invariant => "invariant",
196 FailureStage::Post => "post",
197 FailureStage::Teardown => "teardown",
198 }
199 }
200}
201
202#[derive(Debug, Clone, Copy)]
208pub struct ReceiptWire(pub [u8; RECEIPT_SIZE]);
209
210impl ReceiptWire {
211 pub fn from_slice(buf: &[u8]) -> Result<Self, ReceiptError> {
216 if buf.len() < RECEIPT_SIZE_LEGACY {
217 return Err(ReceiptError::TooShort { got: buf.len() });
218 }
219 let mut bytes = [0u8; RECEIPT_SIZE];
220 let n = core::cmp::min(buf.len(), RECEIPT_SIZE);
221 bytes[..n].copy_from_slice(&buf[..n]);
222 Ok(Self(bytes))
223 }
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub struct DecodedReceipt {
230 pub layout_id: [u8; 8],
232 pub changed_fields: u64,
234 pub changed_bytes: u32,
236 pub changed_regions: u16,
238 pub old_size: u32,
240 pub new_size: u32,
242 pub invariants_checked: u16,
244 pub was_resized: bool,
246 pub invariants_passed: bool,
248 pub cpi_invoked: bool,
250 pub committed: bool,
252 pub had_failure: bool,
254 pub before_fingerprint: [u8; 8],
256 pub after_fingerprint: [u8; 8],
258 pub segment_changed_mask: u16,
260 pub policy_flags: u32,
262 pub journal_appends: u16,
264 pub cpi_count: u8,
266 pub phase: Phase,
268 pub validation_bundle_id: u16,
270 pub compat_impact: CompatImpact,
272 pub migration_flags: u8,
274 pub failed_invariant_idx: u8,
276 pub failed_error_code: u32,
278 pub failure_stage: FailureStage,
280}
281
282impl DecodedReceipt {
283 pub fn parse(buf: &[u8]) -> Result<Self, ReceiptError> {
288 if buf.len() < RECEIPT_SIZE_LEGACY {
289 return Err(ReceiptError::TooShort { got: buf.len() });
290 }
291
292 let mut layout_id = [0u8; 8];
293 layout_id.copy_from_slice(&buf[0..8]);
294
295 let changed_fields = u64::from_le_bytes([
296 buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14], buf[15],
297 ]);
298 let changed_bytes = u32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]);
299 let changed_regions = u16::from_le_bytes([buf[20], buf[21]]);
300 let old_size = u32::from_le_bytes([buf[22], buf[23], buf[24], buf[25]]);
301 let new_size = u32::from_le_bytes([buf[26], buf[27], buf[28], buf[29]]);
302 let invariants_checked = u16::from_le_bytes([buf[30], buf[31]]);
303
304 let flags = buf[32];
305 let was_resized = flags & (1 << 0) != 0;
306 let invariants_passed = flags & (1 << 1) != 0;
307 let cpi_invoked = flags & (1 << 2) != 0;
308 let committed = flags & (1 << 3) != 0;
309 let had_failure = flags & (1 << 4) != 0;
310
311 let mut before_fingerprint = [0u8; 8];
312 before_fingerprint.copy_from_slice(&buf[33..41]);
313 let mut after_fingerprint = [0u8; 8];
314 after_fingerprint.copy_from_slice(&buf[41..49]);
315
316 let segment_changed_mask = u16::from_le_bytes([buf[49], buf[50]]);
317 let policy_flags = u32::from_le_bytes([buf[51], buf[52], buf[53], buf[54]]);
318 let journal_appends = u16::from_le_bytes([buf[55], buf[56]]);
319 let cpi_count = buf[57];
320 let phase = Phase::from_u8(buf[58]).ok_or(ReceiptError::InvalidPhase(buf[58]))?;
321 let validation_bundle_id = u16::from_le_bytes([buf[59], buf[60]]);
322 let compat_impact =
323 CompatImpact::from_u8(buf[61]).ok_or(ReceiptError::InvalidCompatImpact(buf[61]))?;
324 let migration_flags = buf[62];
325
326 let (failed_invariant_idx, failed_error_code, failure_stage) = if buf.len() >= RECEIPT_SIZE
330 {
331 let mut i = 69usize;
335 while i < RECEIPT_SIZE {
336 if buf[i] != 0 {
337 return Err(ReceiptError::ReservedNonZero);
338 }
339 i += 1;
340 }
341 let idx = buf[63];
342 let code = u32::from_le_bytes([buf[64], buf[65], buf[66], buf[67]]);
343 let stage =
344 FailureStage::from_u8(buf[68]).ok_or(ReceiptError::InvalidFailureStage(buf[68]))?;
345 (idx, code, stage)
346 } else {
347 (FAILED_INVARIANT_NONE, 0u32, FailureStage::None)
348 };
349
350 Ok(Self {
351 layout_id,
352 changed_fields,
353 changed_bytes,
354 changed_regions,
355 old_size,
356 new_size,
357 invariants_checked,
358 was_resized,
359 invariants_passed,
360 cpi_invoked,
361 committed,
362 had_failure,
363 before_fingerprint,
364 after_fingerprint,
365 segment_changed_mask,
366 policy_flags,
367 journal_appends,
368 cpi_count,
369 phase,
370 validation_bundle_id,
371 compat_impact,
372 migration_flags,
373 failed_invariant_idx,
374 failed_error_code,
375 failure_stage,
376 })
377 }
378
379 pub fn changed_field_indices(&self) -> ChangedFieldIter {
381 ChangedFieldIter {
382 mask: self.changed_fields,
383 idx: 0,
384 }
385 }
386
387 pub fn changed_segment_indices(&self) -> ChangedSegmentIter {
389 ChangedSegmentIter {
390 mask: self.segment_changed_mask,
391 idx: 0,
392 }
393 }
394
395 pub const fn is_mutation(&self) -> bool {
397 self.committed && (self.changed_bytes > 0 || self.was_resized)
398 }
399
400 pub const fn is_readonly(&self) -> bool {
402 self.committed
403 && !self.was_resized
404 && self.changed_bytes == 0
405 && !self.cpi_invoked
406 && self.journal_appends == 0
407 }
408
409 pub const fn size_delta(&self) -> i64 {
411 (self.new_size as i64) - (self.old_size as i64)
412 }
413}
414
415pub struct ChangedFieldIter {
417 mask: u64,
418 idx: u32,
419}
420
421impl Iterator for ChangedFieldIter {
422 type Item = u32;
423 fn next(&mut self) -> Option<u32> {
424 while self.idx < 64 {
425 let cur = self.idx;
426 let bit = 1u64 << cur;
427 self.idx += 1;
428 if self.mask & bit != 0 {
429 return Some(cur);
430 }
431 }
432 None
433 }
434}
435
436pub struct ChangedSegmentIter {
438 mask: u16,
439 idx: u32,
440}
441
442impl Iterator for ChangedSegmentIter {
443 type Item = u32;
444 fn next(&mut self) -> Option<u32> {
445 while self.idx < 16 {
446 let cur = self.idx;
447 let bit = 1u16 << cur;
448 self.idx += 1;
449 if self.mask & bit != 0 {
450 return Some(cur);
451 }
452 }
453 None
454 }
455}
456
457#[cfg(feature = "narrate")]
458pub mod narrative {
459 use super::{DecodedReceipt, FailureStage};
477 use alloc::string::{String, ToString};
478 use alloc::vec::Vec;
479 use hopper_schema::{ErrorRegistry, LayoutManifest};
480
481 #[derive(Debug, Clone)]
483 pub struct ReceiptNarrative {
484 pub summary: String,
486 pub field_changes: Vec<String>,
488 pub flags: Vec<String>,
490 pub severity: &'static str,
492 pub failure_line: Option<String>,
495 }
496
497 pub struct Narrator<'a> {
500 pub layout: Option<&'a LayoutManifest>,
502 pub errors: Option<&'a ErrorRegistry>,
505 }
506
507 impl<'a> Narrator<'a> {
508 pub const fn with_layout(layout: &'a LayoutManifest) -> Self {
510 Self {
511 layout: Some(layout),
512 errors: None,
513 }
514 }
515
516 pub const fn with_all(layout: &'a LayoutManifest, errors: &'a ErrorRegistry) -> Self {
518 Self {
519 layout: Some(layout),
520 errors: Some(errors),
521 }
522 }
523
524 pub fn narrate(&self, r: &DecodedReceipt) -> ReceiptNarrative {
526 let failure_line = if r.had_failure {
528 Some(render_failure(r, self.errors))
529 } else {
530 None
531 };
532
533 let mut field_changes = Vec::new();
534 for idx in r.changed_field_indices() {
535 let name = self
536 .layout
537 .and_then(|m| m.fields.get(idx as usize))
538 .map(|f| f.name.to_string())
539 .unwrap_or_else(|| format!("field[{}]", idx));
540 field_changes.push(name);
541 }
542
543 let mut flags = Vec::new();
544 if r.was_resized {
545 flags.push(format!(
546 "resized {} → {} bytes (Δ {})",
547 r.old_size,
548 r.new_size,
549 r.size_delta()
550 ));
551 }
552 if r.cpi_invoked {
553 flags.push(format!("invoked {} CPI(s)", r.cpi_count));
554 }
555 if r.journal_appends > 0 {
556 flags.push(format!("appended {} journal entr(ies)", r.journal_appends));
557 }
558 if r.migration_flags != 0 {
559 flags.push(format!("migration flags = 0x{:02x}", r.migration_flags));
560 }
561
562 let (summary, severity) = summarize(r, &field_changes, failure_line.as_deref());
563
564 ReceiptNarrative {
565 summary,
566 field_changes,
567 flags,
568 severity,
569 failure_line,
570 }
571 }
572 }
573
574 fn render_failure(r: &DecodedReceipt, errors: Option<&ErrorRegistry>) -> String {
579 let stage_label = r.failure_stage.name();
580 if let Some(reg) = errors {
582 if let Some(desc) = reg.find_by_code(r.failed_error_code) {
583 if !desc.invariant.is_empty() {
584 return format!(
585 "Execution aborted at {} stage: invariant `{}` failed \
586 ({}::{} = 0x{:x}).",
587 stage_label, desc.invariant, reg.enum_name, desc.name, desc.code,
588 );
589 }
590 return format!(
591 "Execution aborted at {} stage: {}::{} (code 0x{:x}).",
592 stage_label, reg.enum_name, desc.name, desc.code,
593 );
594 }
595 }
596 if r.failure_stage == FailureStage::Invariant
598 && r.failed_invariant_idx != super::FAILED_INVARIANT_NONE
599 {
600 format!(
601 "Execution aborted at invariant stage: invariant #{} failed (code 0x{:x}).",
602 r.failed_invariant_idx, r.failed_error_code
603 )
604 } else {
605 format!(
606 "Execution aborted at {} stage: error code 0x{:x}.",
607 stage_label, r.failed_error_code
608 )
609 }
610 }
611
612 fn summarize(
613 r: &DecodedReceipt,
614 changed: &[String],
615 failure_line: Option<&str>,
616 ) -> (String, &'static str) {
617 if let Some(line) = failure_line {
618 return (line.to_string(), "error");
619 }
620 if !r.committed {
621 return (
622 format!(
623 "Frame rolled back in phase '{}' (invariants {}/{}).",
624 r.phase.name(),
625 if r.invariants_passed {
626 "passed"
627 } else {
628 "failed"
629 },
630 r.invariants_checked
631 ),
632 "warn",
633 );
634 }
635 if r.is_readonly() {
636 return (
637 format!(
638 "Read-through committed at phase '{}'; no state mutated.",
639 r.phase.name()
640 ),
641 "info",
642 );
643 }
644 let names = if changed.is_empty() {
645 "no named fields".to_string()
646 } else if changed.len() <= 3 {
647 changed.join(", ")
648 } else {
649 format!("{} and {} more", changed[..3].join(", "), changed.len() - 3)
650 };
651 let severity = if r.compat_impact as u8 >= super::CompatImpact::Migration as u8 {
652 "warn"
653 } else if r.compat_impact as u8 >= super::CompatImpact::Append as u8 {
654 "notice"
655 } else {
656 "info"
657 };
658 (
659 format!(
660 "Committed at phase '{}': mutated {} ({} byte{}, {} region{}), compat={}.",
661 r.phase.name(),
662 names,
663 r.changed_bytes,
664 if r.changed_bytes == 1 { "" } else { "s" },
665 r.changed_regions,
666 if r.changed_regions == 1 { "" } else { "s" },
667 r.compat_impact.name(),
668 ),
669 severity,
670 )
671 }
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677
678 fn sample_wire() -> [u8; RECEIPT_SIZE] {
679 let mut b = [0u8; RECEIPT_SIZE];
680 b[0..8].copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]); b[8..16].copy_from_slice(&(0b1011u64).to_le_bytes()); b[16..20].copy_from_slice(&16u32.to_le_bytes()); b[20..22].copy_from_slice(&2u16.to_le_bytes()); b[22..26].copy_from_slice(&128u32.to_le_bytes()); b[26..30].copy_from_slice(&128u32.to_le_bytes()); b[30..32].copy_from_slice(&3u16.to_le_bytes()); b[32] = (1 << 1) | (1 << 3);
689 b[33..41].copy_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0x00, 0x00, 0x00, 0x00]);
690 b[41..49].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x00, 0x00, 0x00, 0x00]);
691 b[49..51].copy_from_slice(&0b10u16.to_le_bytes()); b[51..55].copy_from_slice(&0x42u32.to_le_bytes()); b[55..57].copy_from_slice(&0u16.to_le_bytes()); b[57] = 0; b[58] = 0; b[59..61].copy_from_slice(&7u16.to_le_bytes()); b[61] = 0; b[62] = 0; b[63] = FAILED_INVARIANT_NONE; b[64..68].copy_from_slice(&0u32.to_le_bytes()); b[68] = 0; b
704 }
705
706 #[test]
707 fn parses_valid_wire() {
708 let wire = sample_wire();
709 let r = DecodedReceipt::parse(&wire).expect("should parse");
710 assert_eq!(r.phase, Phase::Update);
711 assert!(r.committed);
712 assert!(r.invariants_passed);
713 assert_eq!(r.changed_fields, 0b1011);
714 assert_eq!(r.changed_bytes, 16);
715 assert_eq!(r.compat_impact, CompatImpact::None);
716 assert_eq!(r.validation_bundle_id, 7);
717 assert!(!r.had_failure);
718 assert_eq!(r.failed_error_code, 0);
719 assert_eq!(r.failed_invariant_idx, FAILED_INVARIANT_NONE);
720 assert!(!r.is_readonly());
721 assert!(r.is_mutation());
723 }
724
725 #[test]
726 fn rejects_short() {
727 let buf = [0u8; 32];
728 assert!(matches!(
729 DecodedReceipt::parse(&buf),
730 Err(ReceiptError::TooShort { got: 32 })
731 ));
732 }
733
734 #[test]
735 fn accepts_legacy_64_byte_receipt() {
736 let wire = sample_wire();
737 let legacy = &wire[..RECEIPT_SIZE_LEGACY];
738 let r = DecodedReceipt::parse(legacy).expect("should parse legacy");
739 assert!(!r.had_failure);
740 assert_eq!(r.failed_invariant_idx, FAILED_INVARIANT_NONE);
741 assert_eq!(r.failed_error_code, 0);
742 assert_eq!(r.failure_stage, FailureStage::None);
743 }
744
745 #[test]
746 fn decodes_invariant_failure() {
747 let mut wire = sample_wire();
748 wire[32] = (1 << 3) | (1 << 4); wire[63] = 0x02; wire[64..68].copy_from_slice(&0x1001u32.to_le_bytes()); wire[68] = 3; let r = DecodedReceipt::parse(&wire).expect("should parse failure");
754 assert!(r.had_failure);
755 assert!(!r.invariants_passed);
756 assert_eq!(r.failed_invariant_idx, 0x02);
757 assert_eq!(r.failed_error_code, 0x1001);
758 assert_eq!(r.failure_stage, FailureStage::Invariant);
759 }
760
761 #[test]
762 fn rejects_reserved_nonzero() {
763 let mut wire = sample_wire();
764 wire[70] = 1; assert!(matches!(
766 DecodedReceipt::parse(&wire),
767 Err(ReceiptError::ReservedNonZero)
768 ));
769 }
770
771 #[test]
772 fn changed_field_iter_enumerates_bits() {
773 let wire = sample_wire();
774 let r = DecodedReceipt::parse(&wire).unwrap();
775 let indices: alloc::vec::Vec<u32> = r.changed_field_indices().collect();
776 assert_eq!(indices, alloc::vec![0u32, 1u32, 3u32]);
777 }
778
779 #[test]
780 fn changed_segment_iter_enumerates_bits() {
781 let wire = sample_wire();
782 let r = DecodedReceipt::parse(&wire).unwrap();
783 let indices: alloc::vec::Vec<u32> = r.changed_segment_indices().collect();
784 assert_eq!(indices, alloc::vec![1u32]);
785 }
786}