pub const RECEIPT_SIZE: usize = 72;
pub const RECEIPT_SIZE_LEGACY: usize = 64;
pub const FAILED_INVARIANT_NONE: u8 = 0xFF;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReceiptError {
TooShort {
got: usize,
},
ReservedNonZero,
InvalidPhase(u8),
InvalidCompatImpact(u8),
InvalidFailureStage(u8),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Phase {
Update = 0,
Init = 1,
Close = 2,
Migrate = 3,
ReadOnly = 4,
}
impl Phase {
fn from_u8(v: u8) -> Option<Self> {
Some(match v {
0 => Phase::Update,
1 => Phase::Init,
2 => Phase::Close,
3 => Phase::Migrate,
4 => Phase::ReadOnly,
_ => return None,
})
}
pub const fn name(self) -> &'static str {
match self {
Phase::Update => "update",
Phase::Init => "init",
Phase::Close => "close",
Phase::Migrate => "migrate",
Phase::ReadOnly => "readonly",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum CompatImpact {
None = 0,
Append = 1,
Migration = 2,
Breaking = 3,
}
impl CompatImpact {
fn from_u8(v: u8) -> Option<Self> {
Some(match v {
0 => CompatImpact::None,
1 => CompatImpact::Append,
2 => CompatImpact::Migration,
3 => CompatImpact::Breaking,
_ => return None,
})
}
pub const fn name(self) -> &'static str {
match self {
CompatImpact::None => "none",
CompatImpact::Append => "append",
CompatImpact::Migration => "migration",
CompatImpact::Breaking => "breaking",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum FailureStage {
None = 0,
Validation = 1,
Handler = 2,
Invariant = 3,
Post = 4,
Teardown = 5,
}
impl FailureStage {
fn from_u8(v: u8) -> Option<Self> {
Some(match v {
0 => FailureStage::None,
1 => FailureStage::Validation,
2 => FailureStage::Handler,
3 => FailureStage::Invariant,
4 => FailureStage::Post,
5 => FailureStage::Teardown,
_ => return None,
})
}
pub const fn name(self) -> &'static str {
match self {
FailureStage::None => "none",
FailureStage::Validation => "validation",
FailureStage::Handler => "handler",
FailureStage::Invariant => "invariant",
FailureStage::Post => "post",
FailureStage::Teardown => "teardown",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct ReceiptWire(pub [u8; RECEIPT_SIZE]);
impl ReceiptWire {
pub fn from_slice(buf: &[u8]) -> Result<Self, ReceiptError> {
if buf.len() < RECEIPT_SIZE_LEGACY {
return Err(ReceiptError::TooShort { got: buf.len() });
}
let mut bytes = [0u8; RECEIPT_SIZE];
let n = core::cmp::min(buf.len(), RECEIPT_SIZE);
bytes[..n].copy_from_slice(&buf[..n]);
Ok(Self(bytes))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DecodedReceipt {
pub layout_id: [u8; 8],
pub changed_fields: u64,
pub changed_bytes: u32,
pub changed_regions: u16,
pub old_size: u32,
pub new_size: u32,
pub invariants_checked: u16,
pub was_resized: bool,
pub invariants_passed: bool,
pub cpi_invoked: bool,
pub committed: bool,
pub had_failure: bool,
pub before_fingerprint: [u8; 8],
pub after_fingerprint: [u8; 8],
pub segment_changed_mask: u16,
pub policy_flags: u32,
pub journal_appends: u16,
pub cpi_count: u8,
pub phase: Phase,
pub validation_bundle_id: u16,
pub compat_impact: CompatImpact,
pub migration_flags: u8,
pub failed_invariant_idx: u8,
pub failed_error_code: u32,
pub failure_stage: FailureStage,
}
impl DecodedReceipt {
pub fn parse(buf: &[u8]) -> Result<Self, ReceiptError> {
if buf.len() < RECEIPT_SIZE_LEGACY {
return Err(ReceiptError::TooShort { got: buf.len() });
}
let mut layout_id = [0u8; 8];
layout_id.copy_from_slice(&buf[0..8]);
let changed_fields = u64::from_le_bytes([
buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14], buf[15],
]);
let changed_bytes = u32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]);
let changed_regions = u16::from_le_bytes([buf[20], buf[21]]);
let old_size = u32::from_le_bytes([buf[22], buf[23], buf[24], buf[25]]);
let new_size = u32::from_le_bytes([buf[26], buf[27], buf[28], buf[29]]);
let invariants_checked = u16::from_le_bytes([buf[30], buf[31]]);
let flags = buf[32];
let was_resized = flags & (1 << 0) != 0;
let invariants_passed = flags & (1 << 1) != 0;
let cpi_invoked = flags & (1 << 2) != 0;
let committed = flags & (1 << 3) != 0;
let had_failure = flags & (1 << 4) != 0;
let mut before_fingerprint = [0u8; 8];
before_fingerprint.copy_from_slice(&buf[33..41]);
let mut after_fingerprint = [0u8; 8];
after_fingerprint.copy_from_slice(&buf[41..49]);
let segment_changed_mask = u16::from_le_bytes([buf[49], buf[50]]);
let policy_flags = u32::from_le_bytes([buf[51], buf[52], buf[53], buf[54]]);
let journal_appends = u16::from_le_bytes([buf[55], buf[56]]);
let cpi_count = buf[57];
let phase = Phase::from_u8(buf[58]).ok_or(ReceiptError::InvalidPhase(buf[58]))?;
let validation_bundle_id = u16::from_le_bytes([buf[59], buf[60]]);
let compat_impact =
CompatImpact::from_u8(buf[61]).ok_or(ReceiptError::InvalidCompatImpact(buf[61]))?;
let migration_flags = buf[62];
let (failed_invariant_idx, failed_error_code, failure_stage) = if buf.len() >= RECEIPT_SIZE
{
let mut i = 69usize;
while i < RECEIPT_SIZE {
if buf[i] != 0 {
return Err(ReceiptError::ReservedNonZero);
}
i += 1;
}
let idx = buf[63];
let code = u32::from_le_bytes([buf[64], buf[65], buf[66], buf[67]]);
let stage =
FailureStage::from_u8(buf[68]).ok_or(ReceiptError::InvalidFailureStage(buf[68]))?;
(idx, code, stage)
} else {
(FAILED_INVARIANT_NONE, 0u32, FailureStage::None)
};
Ok(Self {
layout_id,
changed_fields,
changed_bytes,
changed_regions,
old_size,
new_size,
invariants_checked,
was_resized,
invariants_passed,
cpi_invoked,
committed,
had_failure,
before_fingerprint,
after_fingerprint,
segment_changed_mask,
policy_flags,
journal_appends,
cpi_count,
phase,
validation_bundle_id,
compat_impact,
migration_flags,
failed_invariant_idx,
failed_error_code,
failure_stage,
})
}
pub fn changed_field_indices(&self) -> ChangedFieldIter {
ChangedFieldIter {
mask: self.changed_fields,
idx: 0,
}
}
pub fn changed_segment_indices(&self) -> ChangedSegmentIter {
ChangedSegmentIter {
mask: self.segment_changed_mask,
idx: 0,
}
}
pub const fn is_mutation(&self) -> bool {
self.committed && (self.changed_bytes > 0 || self.was_resized)
}
pub const fn is_readonly(&self) -> bool {
self.committed
&& !self.was_resized
&& self.changed_bytes == 0
&& !self.cpi_invoked
&& self.journal_appends == 0
}
pub const fn size_delta(&self) -> i64 {
(self.new_size as i64) - (self.old_size as i64)
}
}
pub struct ChangedFieldIter {
mask: u64,
idx: u32,
}
impl Iterator for ChangedFieldIter {
type Item = u32;
fn next(&mut self) -> Option<u32> {
while self.idx < 64 {
let cur = self.idx;
let bit = 1u64 << cur;
self.idx += 1;
if self.mask & bit != 0 {
return Some(cur);
}
}
None
}
}
pub struct ChangedSegmentIter {
mask: u16,
idx: u32,
}
impl Iterator for ChangedSegmentIter {
type Item = u32;
fn next(&mut self) -> Option<u32> {
while self.idx < 16 {
let cur = self.idx;
let bit = 1u16 << cur;
self.idx += 1;
if self.mask & bit != 0 {
return Some(cur);
}
}
None
}
}
#[cfg(feature = "narrate")]
pub mod narrative {
use super::{DecodedReceipt, FailureStage};
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use hopper_schema::{ErrorRegistry, LayoutManifest};
#[derive(Debug, Clone)]
pub struct ReceiptNarrative {
pub summary: String,
pub field_changes: Vec<String>,
pub flags: Vec<String>,
pub severity: &'static str,
pub failure_line: Option<String>,
}
pub struct Narrator<'a> {
pub layout: Option<&'a LayoutManifest>,
pub errors: Option<&'a ErrorRegistry>,
}
impl<'a> Narrator<'a> {
pub const fn with_layout(layout: &'a LayoutManifest) -> Self {
Self {
layout: Some(layout),
errors: None,
}
}
pub const fn with_all(layout: &'a LayoutManifest, errors: &'a ErrorRegistry) -> Self {
Self {
layout: Some(layout),
errors: Some(errors),
}
}
pub fn narrate(&self, r: &DecodedReceipt) -> ReceiptNarrative {
let failure_line = if r.had_failure {
Some(render_failure(r, self.errors))
} else {
None
};
let mut field_changes = Vec::new();
for idx in r.changed_field_indices() {
let name = self
.layout
.and_then(|m| m.fields.get(idx as usize))
.map(|f| f.name.to_string())
.unwrap_or_else(|| format!("field[{}]", idx));
field_changes.push(name);
}
let mut flags = Vec::new();
if r.was_resized {
flags.push(format!(
"resized {} → {} bytes (Δ {})",
r.old_size,
r.new_size,
r.size_delta()
));
}
if r.cpi_invoked {
flags.push(format!("invoked {} CPI(s)", r.cpi_count));
}
if r.journal_appends > 0 {
flags.push(format!("appended {} journal entr(ies)", r.journal_appends));
}
if r.migration_flags != 0 {
flags.push(format!("migration flags = 0x{:02x}", r.migration_flags));
}
let (summary, severity) = summarize(r, &field_changes, failure_line.as_deref());
ReceiptNarrative {
summary,
field_changes,
flags,
severity,
failure_line,
}
}
}
fn render_failure(r: &DecodedReceipt, errors: Option<&ErrorRegistry>) -> String {
let stage_label = r.failure_stage.name();
if let Some(reg) = errors {
if let Some(desc) = reg.find_by_code(r.failed_error_code) {
if !desc.invariant.is_empty() {
return format!(
"Execution aborted at {} stage: invariant `{}` failed \
({}::{} = 0x{:x}).",
stage_label, desc.invariant, reg.enum_name, desc.name, desc.code,
);
}
return format!(
"Execution aborted at {} stage: {}::{} (code 0x{:x}).",
stage_label, reg.enum_name, desc.name, desc.code,
);
}
}
if r.failure_stage == FailureStage::Invariant
&& r.failed_invariant_idx != super::FAILED_INVARIANT_NONE
{
format!(
"Execution aborted at invariant stage: invariant #{} failed (code 0x{:x}).",
r.failed_invariant_idx, r.failed_error_code
)
} else {
format!(
"Execution aborted at {} stage: error code 0x{:x}.",
stage_label, r.failed_error_code
)
}
}
fn summarize(
r: &DecodedReceipt,
changed: &[String],
failure_line: Option<&str>,
) -> (String, &'static str) {
if let Some(line) = failure_line {
return (line.to_string(), "error");
}
if !r.committed {
return (
format!(
"Frame rolled back in phase '{}' (invariants {}/{}).",
r.phase.name(),
if r.invariants_passed {
"passed"
} else {
"failed"
},
r.invariants_checked
),
"warn",
);
}
if r.is_readonly() {
return (
format!(
"Read-through committed at phase '{}'; no state mutated.",
r.phase.name()
),
"info",
);
}
let names = if changed.is_empty() {
"no named fields".to_string()
} else if changed.len() <= 3 {
changed.join(", ")
} else {
format!("{} and {} more", changed[..3].join(", "), changed.len() - 3)
};
let severity = if r.compat_impact as u8 >= super::CompatImpact::Migration as u8 {
"warn"
} else if r.compat_impact as u8 >= super::CompatImpact::Append as u8 {
"notice"
} else {
"info"
};
(
format!(
"Committed at phase '{}': mutated {} ({} byte{}, {} region{}), compat={}.",
r.phase.name(),
names,
r.changed_bytes,
if r.changed_bytes == 1 { "" } else { "s" },
r.changed_regions,
if r.changed_regions == 1 { "" } else { "s" },
r.compat_impact.name(),
),
severity,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_wire() -> [u8; RECEIPT_SIZE] {
let mut b = [0u8; RECEIPT_SIZE];
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);
b[33..41].copy_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0x00, 0x00, 0x00, 0x00]);
b[41..49].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x00, 0x00, 0x00, 0x00]);
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
}
#[test]
fn parses_valid_wire() {
let wire = sample_wire();
let r = DecodedReceipt::parse(&wire).expect("should parse");
assert_eq!(r.phase, Phase::Update);
assert!(r.committed);
assert!(r.invariants_passed);
assert_eq!(r.changed_fields, 0b1011);
assert_eq!(r.changed_bytes, 16);
assert_eq!(r.compat_impact, CompatImpact::None);
assert_eq!(r.validation_bundle_id, 7);
assert!(!r.had_failure);
assert_eq!(r.failed_error_code, 0);
assert_eq!(r.failed_invariant_idx, FAILED_INVARIANT_NONE);
assert!(!r.is_readonly());
assert!(r.is_mutation());
}
#[test]
fn rejects_short() {
let buf = [0u8; 32];
assert!(matches!(
DecodedReceipt::parse(&buf),
Err(ReceiptError::TooShort { got: 32 })
));
}
#[test]
fn accepts_legacy_64_byte_receipt() {
let wire = sample_wire();
let legacy = &wire[..RECEIPT_SIZE_LEGACY];
let r = DecodedReceipt::parse(legacy).expect("should parse legacy");
assert!(!r.had_failure);
assert_eq!(r.failed_invariant_idx, FAILED_INVARIANT_NONE);
assert_eq!(r.failed_error_code, 0);
assert_eq!(r.failure_stage, FailureStage::None);
}
#[test]
fn decodes_invariant_failure() {
let mut wire = sample_wire();
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");
assert!(r.had_failure);
assert!(!r.invariants_passed);
assert_eq!(r.failed_invariant_idx, 0x02);
assert_eq!(r.failed_error_code, 0x1001);
assert_eq!(r.failure_stage, FailureStage::Invariant);
}
#[test]
fn rejects_reserved_nonzero() {
let mut wire = sample_wire();
wire[70] = 1; assert!(matches!(
DecodedReceipt::parse(&wire),
Err(ReceiptError::ReservedNonZero)
));
}
#[test]
fn changed_field_iter_enumerates_bits() {
let wire = sample_wire();
let r = DecodedReceipt::parse(&wire).unwrap();
let indices: alloc::vec::Vec<u32> = r.changed_field_indices().collect();
assert_eq!(indices, alloc::vec![0u32, 1u32, 3u32]);
}
#[test]
fn changed_segment_iter_enumerates_bits() {
let wire = sample_wire();
let r = DecodedReceipt::parse(&wire).unwrap();
let indices: alloc::vec::Vec<u32> = r.changed_segment_indices().collect();
assert_eq!(indices, alloc::vec![1u32]);
}
}