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
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
284pub struct ReceiptIndexRecord {
285 pub index_key: [u8; 16],
287 pub layout_id: [u8; 8],
289 pub phase: Phase,
291 pub compat_impact: CompatImpact,
293 pub migration_flags: u8,
295 pub changed_fields: u64,
297 pub changed_field_count: u32,
299 pub segment_changed_mask: u16,
301 pub changed_segment_count: u32,
303 pub policy_flags: u32,
305 pub validation_bundle_id: u16,
307 pub old_size: u32,
309 pub new_size: u32,
311 pub changed_bytes: u32,
313 pub had_failure: bool,
315 pub failed_error_code: u32,
317 pub failed_invariant_idx: u8,
319 pub failure_stage: FailureStage,
321}
322
323impl DecodedReceipt {
324 pub fn parse(buf: &[u8]) -> Result<Self, ReceiptError> {
329 if buf.len() < RECEIPT_SIZE_LEGACY {
330 return Err(ReceiptError::TooShort { got: buf.len() });
331 }
332
333 let mut layout_id = [0u8; 8];
334 layout_id.copy_from_slice(&buf[0..8]);
335
336 let changed_fields = u64::from_le_bytes([
337 buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14], buf[15],
338 ]);
339 let changed_bytes = u32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]);
340 let changed_regions = u16::from_le_bytes([buf[20], buf[21]]);
341 let old_size = u32::from_le_bytes([buf[22], buf[23], buf[24], buf[25]]);
342 let new_size = u32::from_le_bytes([buf[26], buf[27], buf[28], buf[29]]);
343 let invariants_checked = u16::from_le_bytes([buf[30], buf[31]]);
344
345 let flags = buf[32];
346 let was_resized = flags & (1 << 0) != 0;
347 let invariants_passed = flags & (1 << 1) != 0;
348 let cpi_invoked = flags & (1 << 2) != 0;
349 let committed = flags & (1 << 3) != 0;
350 let had_failure = flags & (1 << 4) != 0;
351
352 let mut before_fingerprint = [0u8; 8];
353 before_fingerprint.copy_from_slice(&buf[33..41]);
354 let mut after_fingerprint = [0u8; 8];
355 after_fingerprint.copy_from_slice(&buf[41..49]);
356
357 let segment_changed_mask = u16::from_le_bytes([buf[49], buf[50]]);
358 let policy_flags = u32::from_le_bytes([buf[51], buf[52], buf[53], buf[54]]);
359 let journal_appends = u16::from_le_bytes([buf[55], buf[56]]);
360 let cpi_count = buf[57];
361 let phase = Phase::from_u8(buf[58]).ok_or(ReceiptError::InvalidPhase(buf[58]))?;
362 let validation_bundle_id = u16::from_le_bytes([buf[59], buf[60]]);
363 let compat_impact =
364 CompatImpact::from_u8(buf[61]).ok_or(ReceiptError::InvalidCompatImpact(buf[61]))?;
365 let migration_flags = buf[62];
366
367 let (failed_invariant_idx, failed_error_code, failure_stage) = if buf.len() >= RECEIPT_SIZE
371 {
372 let mut i = 69usize;
376 while i < RECEIPT_SIZE {
377 if buf[i] != 0 {
378 return Err(ReceiptError::ReservedNonZero);
379 }
380 i += 1;
381 }
382 let idx = buf[63];
383 let code = u32::from_le_bytes([buf[64], buf[65], buf[66], buf[67]]);
384 let stage =
385 FailureStage::from_u8(buf[68]).ok_or(ReceiptError::InvalidFailureStage(buf[68]))?;
386 (idx, code, stage)
387 } else {
388 (FAILED_INVARIANT_NONE, 0u32, FailureStage::None)
389 };
390
391 Ok(Self {
392 layout_id,
393 changed_fields,
394 changed_bytes,
395 changed_regions,
396 old_size,
397 new_size,
398 invariants_checked,
399 was_resized,
400 invariants_passed,
401 cpi_invoked,
402 committed,
403 had_failure,
404 before_fingerprint,
405 after_fingerprint,
406 segment_changed_mask,
407 policy_flags,
408 journal_appends,
409 cpi_count,
410 phase,
411 validation_bundle_id,
412 compat_impact,
413 migration_flags,
414 failed_invariant_idx,
415 failed_error_code,
416 failure_stage,
417 })
418 }
419
420 pub fn changed_field_indices(&self) -> ChangedFieldIter {
422 ChangedFieldIter {
423 mask: self.changed_fields,
424 idx: 0,
425 }
426 }
427
428 pub const fn changed_field_count(&self) -> u32 {
430 self.changed_fields.count_ones()
431 }
432
433 pub fn changed_segment_indices(&self) -> ChangedSegmentIter {
435 ChangedSegmentIter {
436 mask: self.segment_changed_mask,
437 idx: 0,
438 }
439 }
440
441 pub const fn changed_segment_count(&self) -> u32 {
443 self.segment_changed_mask.count_ones()
444 }
445
446 pub fn index_key(&self) -> [u8; 16] {
448 let mut key = [0u8; 16];
449 key[..8].copy_from_slice(&self.layout_id);
450 key[8..].copy_from_slice(&self.after_fingerprint);
451 key
452 }
453
454 pub fn index_record(&self) -> ReceiptIndexRecord {
456 ReceiptIndexRecord {
457 index_key: self.index_key(),
458 layout_id: self.layout_id,
459 phase: self.phase,
460 compat_impact: self.compat_impact,
461 migration_flags: self.migration_flags,
462 changed_fields: self.changed_fields,
463 changed_field_count: self.changed_field_count(),
464 segment_changed_mask: self.segment_changed_mask,
465 changed_segment_count: self.changed_segment_count(),
466 policy_flags: self.policy_flags,
467 validation_bundle_id: self.validation_bundle_id,
468 old_size: self.old_size,
469 new_size: self.new_size,
470 changed_bytes: self.changed_bytes,
471 had_failure: self.had_failure,
472 failed_error_code: self.failed_error_code,
473 failed_invariant_idx: self.failed_invariant_idx,
474 failure_stage: self.failure_stage,
475 }
476 }
477
478 pub const fn is_mutation(&self) -> bool {
480 self.committed && (self.changed_bytes > 0 || self.was_resized)
481 }
482
483 pub const fn is_readonly(&self) -> bool {
485 self.committed
486 && !self.was_resized
487 && self.changed_bytes == 0
488 && !self.cpi_invoked
489 && self.journal_appends == 0
490 }
491
492 pub const fn size_delta(&self) -> i64 {
494 (self.new_size as i64) - (self.old_size as i64)
495 }
496}
497
498pub struct ChangedFieldIter {
500 mask: u64,
501 idx: u32,
502}
503
504impl Iterator for ChangedFieldIter {
505 type Item = u32;
506 fn next(&mut self) -> Option<u32> {
507 while self.idx < 64 {
508 let cur = self.idx;
509 let bit = 1u64 << cur;
510 self.idx += 1;
511 if self.mask & bit != 0 {
512 return Some(cur);
513 }
514 }
515 None
516 }
517}
518
519pub struct ChangedSegmentIter {
521 mask: u16,
522 idx: u32,
523}
524
525impl Iterator for ChangedSegmentIter {
526 type Item = u32;
527 fn next(&mut self) -> Option<u32> {
528 while self.idx < 16 {
529 let cur = self.idx;
530 let bit = 1u16 << cur;
531 self.idx += 1;
532 if self.mask & bit != 0 {
533 return Some(cur);
534 }
535 }
536 None
537 }
538}
539
540#[cfg(feature = "narrate")]
541pub mod narrative {
542 use super::{DecodedReceipt, FailureStage};
560 use alloc::string::{String, ToString};
561 use alloc::vec::Vec;
562 use hopper_schema::{ErrorRegistry, LayoutManifest};
563
564 #[derive(Debug, Clone)]
566 pub struct ReceiptNarrative {
567 pub summary: String,
569 pub field_changes: Vec<String>,
571 pub flags: Vec<String>,
573 pub severity: &'static str,
575 pub failure_line: Option<String>,
578 }
579
580 pub struct Narrator<'a> {
583 pub layout: Option<&'a LayoutManifest>,
585 pub errors: Option<&'a ErrorRegistry>,
588 }
589
590 impl<'a> Narrator<'a> {
591 pub const fn with_layout(layout: &'a LayoutManifest) -> Self {
593 Self {
594 layout: Some(layout),
595 errors: None,
596 }
597 }
598
599 pub const fn with_all(layout: &'a LayoutManifest, errors: &'a ErrorRegistry) -> Self {
601 Self {
602 layout: Some(layout),
603 errors: Some(errors),
604 }
605 }
606
607 pub fn narrate(&self, r: &DecodedReceipt) -> ReceiptNarrative {
609 let failure_line = if r.had_failure {
611 Some(render_failure(r, self.errors))
612 } else {
613 None
614 };
615
616 let mut field_changes = Vec::new();
617 for idx in r.changed_field_indices() {
618 let name = self
619 .layout
620 .and_then(|m| m.fields.get(idx as usize))
621 .map(|f| f.name.to_string())
622 .unwrap_or_else(|| format!("field[{}]", idx));
623 field_changes.push(name);
624 }
625
626 let mut flags = Vec::new();
627 if r.was_resized {
628 flags.push(format!(
629 "resized {} → {} bytes (Δ {})",
630 r.old_size,
631 r.new_size,
632 r.size_delta()
633 ));
634 }
635 if r.cpi_invoked {
636 flags.push(format!("invoked {} CPI(s)", r.cpi_count));
637 }
638 if r.journal_appends > 0 {
639 flags.push(format!("appended {} journal entr(ies)", r.journal_appends));
640 }
641 if r.migration_flags != 0 {
642 flags.push(format!("migration flags = 0x{:02x}", r.migration_flags));
643 }
644
645 let (summary, severity) = summarize(r, &field_changes, failure_line.as_deref());
646
647 ReceiptNarrative {
648 summary,
649 field_changes,
650 flags,
651 severity,
652 failure_line,
653 }
654 }
655 }
656
657 fn render_failure(r: &DecodedReceipt, errors: Option<&ErrorRegistry>) -> String {
662 let stage_label = r.failure_stage.name();
663 if let Some(reg) = errors {
665 if let Some(desc) = reg.find_by_code(r.failed_error_code) {
666 if !desc.invariant.is_empty() {
667 return format!(
668 "Execution aborted at {} stage: invariant `{}` failed \
669 ({}::{} = 0x{:x}).",
670 stage_label, desc.invariant, reg.enum_name, desc.name, desc.code,
671 );
672 }
673 return format!(
674 "Execution aborted at {} stage: {}::{} (code 0x{:x}).",
675 stage_label, reg.enum_name, desc.name, desc.code,
676 );
677 }
678 }
679 if r.failure_stage == FailureStage::Invariant
681 && r.failed_invariant_idx != super::FAILED_INVARIANT_NONE
682 {
683 format!(
684 "Execution aborted at invariant stage: invariant #{} failed (code 0x{:x}).",
685 r.failed_invariant_idx, r.failed_error_code
686 )
687 } else {
688 format!(
689 "Execution aborted at {} stage: error code 0x{:x}.",
690 stage_label, r.failed_error_code
691 )
692 }
693 }
694
695 fn summarize(
696 r: &DecodedReceipt,
697 changed: &[String],
698 failure_line: Option<&str>,
699 ) -> (String, &'static str) {
700 if let Some(line) = failure_line {
701 return (line.to_string(), "error");
702 }
703 if !r.committed {
704 return (
705 format!(
706 "Frame rolled back in phase '{}' (invariants {}/{}).",
707 r.phase.name(),
708 if r.invariants_passed {
709 "passed"
710 } else {
711 "failed"
712 },
713 r.invariants_checked
714 ),
715 "warn",
716 );
717 }
718 if r.is_readonly() {
719 return (
720 format!(
721 "Read-through committed at phase '{}'; no state mutated.",
722 r.phase.name()
723 ),
724 "info",
725 );
726 }
727 let names = if changed.is_empty() {
728 "no named fields".to_string()
729 } else if changed.len() <= 3 {
730 changed.join(", ")
731 } else {
732 format!("{} and {} more", changed[..3].join(", "), changed.len() - 3)
733 };
734 let severity = if r.compat_impact as u8 >= super::CompatImpact::Migration as u8 {
735 "warn"
736 } else if r.compat_impact as u8 >= super::CompatImpact::Append as u8 {
737 "notice"
738 } else {
739 "info"
740 };
741 (
742 format!(
743 "Committed at phase '{}': mutated {} ({} byte{}, {} region{}), compat={}.",
744 r.phase.name(),
745 names,
746 r.changed_bytes,
747 if r.changed_bytes == 1 { "" } else { "s" },
748 r.changed_regions,
749 if r.changed_regions == 1 { "" } else { "s" },
750 r.compat_impact.name(),
751 ),
752 severity,
753 )
754 }
755}
756
757#[cfg(test)]
758mod tests {
759 use super::*;
760
761 fn sample_wire() -> [u8; RECEIPT_SIZE] {
762 let mut b = [0u8; RECEIPT_SIZE];
763 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);
772 b[33..41].copy_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0x00, 0x00, 0x00, 0x00]);
773 b[41..49].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x00, 0x00, 0x00, 0x00]);
774 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
787 }
788
789 #[test]
790 fn parses_valid_wire() {
791 let wire = sample_wire();
792 let r = DecodedReceipt::parse(&wire).expect("should parse");
793 assert_eq!(r.phase, Phase::Update);
794 assert!(r.committed);
795 assert!(r.invariants_passed);
796 assert_eq!(r.changed_fields, 0b1011);
797 assert_eq!(r.changed_bytes, 16);
798 assert_eq!(r.compat_impact, CompatImpact::None);
799 assert_eq!(r.validation_bundle_id, 7);
800 assert!(!r.had_failure);
801 assert_eq!(r.failed_error_code, 0);
802 assert_eq!(r.failed_invariant_idx, FAILED_INVARIANT_NONE);
803 assert!(!r.is_readonly());
804 assert!(r.is_mutation());
806 }
807
808 #[test]
809 fn rejects_short() {
810 let buf = [0u8; 32];
811 assert!(matches!(
812 DecodedReceipt::parse(&buf),
813 Err(ReceiptError::TooShort { got: 32 })
814 ));
815 }
816
817 #[test]
818 fn accepts_legacy_64_byte_receipt() {
819 let wire = sample_wire();
820 let legacy = &wire[..RECEIPT_SIZE_LEGACY];
821 let r = DecodedReceipt::parse(legacy).expect("should parse legacy");
822 assert!(!r.had_failure);
823 assert_eq!(r.failed_invariant_idx, FAILED_INVARIANT_NONE);
824 assert_eq!(r.failed_error_code, 0);
825 assert_eq!(r.failure_stage, FailureStage::None);
826 }
827
828 #[test]
829 fn decodes_invariant_failure() {
830 let mut wire = sample_wire();
831 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");
837 assert!(r.had_failure);
838 assert!(!r.invariants_passed);
839 assert_eq!(r.failed_invariant_idx, 0x02);
840 assert_eq!(r.failed_error_code, 0x1001);
841 assert_eq!(r.failure_stage, FailureStage::Invariant);
842 }
843
844 #[test]
845 fn index_record_projects_filterable_receipt_fields() {
846 let wire = sample_wire();
847 let receipt = DecodedReceipt::parse(&wire).unwrap();
848 let record = receipt.index_record();
849
850 assert_eq!(&record.index_key[..8], &receipt.layout_id);
851 assert_eq!(&record.index_key[8..], &receipt.after_fingerprint);
852 assert_eq!(record.changed_field_count, receipt.changed_field_count());
853 assert_eq!(
854 record.changed_segment_count,
855 receipt.changed_segment_count()
856 );
857 assert_eq!(record.phase, receipt.phase);
858 assert_eq!(record.compat_impact, receipt.compat_impact);
859 assert_eq!(record.validation_bundle_id, 7);
860 }
861
862 #[test]
863 fn rejects_reserved_nonzero() {
864 let mut wire = sample_wire();
865 wire[70] = 1; assert!(matches!(
867 DecodedReceipt::parse(&wire),
868 Err(ReceiptError::ReservedNonZero)
869 ));
870 }
871
872 #[test]
873 fn changed_field_iter_enumerates_bits() {
874 let wire = sample_wire();
875 let r = DecodedReceipt::parse(&wire).unwrap();
876 let indices: alloc::vec::Vec<u32> = r.changed_field_indices().collect();
877 assert_eq!(indices, alloc::vec![0u32, 1u32, 3u32]);
878 }
879
880 #[test]
881 fn changed_segment_iter_enumerates_bits() {
882 let wire = sample_wire();
883 let r = DecodedReceipt::parse(&wire).unwrap();
884 let indices: alloc::vec::Vec<u32> = r.changed_segment_indices().collect();
885 assert_eq!(indices, alloc::vec![1u32]);
886 }
887}