mesh: a cut journal is caught by the receipts the steward handed out, and the receipt chain survives a restart #306

Merged
dcharlot merged 1 commit from feat/mesh-journal-witness into main 2026-10-18 00:42:33 -04:00
4 changed files with 341 additions and 72 deletions

View file

@ -120,13 +120,22 @@ that can restart MUST therefore keep a durable journal of its grant decisions:
backend and the decision time, MUST be written **before** the engine runs. A
serve whose authorisation cannot be recorded is refused.
- A `Debited` entry, holding the metered energy, is written after the debit.
- A `Sealed` entry, holding a receipt's sequence and its envelope's content id,
MUST be written for **every** receipt, granted or not, **before** the receipt
is handed out. A receipt that cannot be recorded is not handed out, and the
receipt chain does not advance past it.
Each entry is a record of one chain: `{seq, prev, entry}`, where `seq` counts
records from 0 and `prev` is the BLAKE3 hash of the previous record's encoding.
On restart the steward MUST re-establish the spend before serving by replaying
the journal through the gate: each authorisation is verified and decided again,
at its recorded time, and each debit applied to the serve it belongs to. A
replay the gate refuses, a debit with no authorisation, or a presentation spent
twice means the journal is not one the steward produced, and the steward MUST
NOT serve from it. Replayed presentations count as spent.
at its recorded time, and each debit applied to the serve it belongs to. Records
that do not form one chain, a replay the gate refuses, a debit with no
authorisation, a presentation spent twice, or a receipt out of sequence means the
journal is not one the steward produced, and the steward MUST NOT serve from it.
Replayed presentations count as spent, and the receipt chain continues from the
last `Sealed` entry (§6.3).
An authorisation with no debit is a serve that was in flight when the steward
stopped, and whether the engine finished is not knowable from the journal. The
@ -135,10 +144,24 @@ at its request's joule ceiling, which is what its authorisation admitted, and is
surfaced as unresolved, naming the caller and the backend that held it, for an
operator to settle.
The reference journal is an append-only file of one JSON entry per line, each
**A cut journal.** Because the records form one chain, a record removed from the
middle, edited or moved fails the replay. Records cut from the **end** leave a
chain that is whole, and nothing on the steward's own storage can show they
existed; but every receipt the steward handed out was recorded first, so the
receipts in callers' hands can. Any party may show the steward a receipt it
sealed, with its envelope. If the steward's journal does not hold that receipt,
records were cut after it left, the spend the steward replayed is less than it
signed for, and the steward MUST refuse every grant from then on, until an
operator restores the journal. A caller SHOULD show its latest receipt from a
steward after the steward restarts. A cut that removes only a serve that
produced no receipt, one that was in flight, leaves no evidence outside the
steward.
The reference journal is an append-only file of one JSON record per line, each
synced before the append returns. A final line with no newline is an append cut
short and is dropped: an authorisation never recorded never started its serve,
and a debit never recorded leaves its serve unresolved.
short and is dropped: an authorisation never recorded never started its serve, a
debit never recorded leaves its serve unresolved, and a seal never recorded was
never handed out.
---
@ -431,6 +454,10 @@ A steward keeps a receipt log. Appending a receipt stamps its `sequence` (from 0
and `prev` (the content id of the previous sealed envelope; absent at genesis),
seals it, and advances the head. The log is tamper-evident end to end: altering
any envelope breaks verification of that receipt and the chain link from the next.
A steward that restarts continues its chain from its journal (§2.2): the next
receipt takes the next sequence and chains to the last envelope sealed, rather
than starting a new chain at 0, so a restart is not mistaken for a log that lost
its history.
---
@ -446,7 +473,9 @@ A conforming implementation MUST:
or has less left than the request's joule ceiling; debit the metered joules
against every link after the work, recording a breach on the receipt.
2. Where the steward can restart, journal each decision before its serve runs
and re-establish spend from the journal before serving again (§2.2).
and each receipt before it is handed out, re-establish spend and the receipt
chain from the journal before serving again, and refuse grants once shown a
receipt the journal does not hold (§2.2).
3. Choose the cheapest admissible backend and record every candidate weighed
(§4).
4. Meter energy around the engine and tag provenance honestly, enforcing the
@ -484,7 +513,9 @@ cryptography. These properties are correctness, not style:
replayed through the gate on restart (§2.2), so a restart gives no budget back
and a crash mid-serve leaves the serve debited and unresolved rather than
free. A journal is only as durable as the storage under it, and a steward
with an in-memory journal has no such guarantee.
with an in-memory journal has no such guarantee. Records cut from its end are
invisible to the steward, but not to whoever holds a receipt it sealed after
the cut point: shown one, the steward stops honouring grants.
- **Honesty is enforced at seal time.** A receipt cannot claim a measurement it
does not have (§3.3). An operator without a hardware meter produces honest
`Estimator` receipts.

View file

@ -1,5 +1,5 @@
//! The steward's decision journal (SPEC §2.2): where a grant's spend at this
//! steward survives the process (JCP §4.11).
//! steward, and the steward's receipt chain, survive the process (JCP §4.11).
use std::fs::{File, OpenOptions};
use std::io::{self, Read, Write};
@ -28,6 +28,37 @@ pub enum JournalEntry {
},
/// The serve's metered joules were debited against the grant.
Debited { grant_id: ContentId, nonce: [u8; 16], energy: Energy },
/// A receipt was sealed. Written before the receipt is handed out, so
/// every receipt that leaves the steward has a record here, and the
/// receipt chain continues from it after a restart.
Sealed {
/// The receipt's position in the steward's receipt chain.
sequence: u64,
/// The content id of its sealed envelope.
envelope: ContentId,
},
}
/// One line of the journal: an entry, its position, and the hash of the
/// record before it. The records form one chain, so a record removed from the
/// middle, or edited, or moved, breaks it; only records cut from the end leave
/// a chain that is whole, which is what [`Mesh::witness`](crate::Mesh::witness)
/// is for.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct JournalRecord {
pub seq: u64,
/// The [`head`](Self::head) of the record before this one; `None` for the
/// first.
pub prev: Option<ContentId>,
pub entry: JournalEntry,
}
impl JournalRecord {
/// The record's hash: BLAKE3 over its JSON encoding, which is the bytes a
/// [`FileJournal`] stores.
pub fn head(&self) -> ContentId {
ContentId::of_bytes(&serde_json::to_vec(self).expect("a journal record encodes as JSON"))
}
}
/// A serve the journal shows authorised and never debited: the steward stopped
@ -46,11 +77,12 @@ pub struct Unresolved {
pub debited_uj: u64,
}
/// Where a steward records its grant decisions.
/// Where a steward records its decisions and seals.
pub trait Journal {
/// Record one entry durably. An error refuses the serve it belongs to: a
/// decision that could not be recorded is not acted on.
fn append(&mut self, entry: &JournalEntry) -> io::Result<()>;
/// Record one record durably. An error refuses what the record was for: a
/// decision that could not be recorded is not acted on, and a receipt that
/// could not be recorded is not handed out.
fn append(&mut self, record: &JournalRecord) -> io::Result<()>;
}
/// A journal in memory, and the default. It is enough for a steward that never
@ -58,24 +90,24 @@ pub trait Journal {
/// [`FileJournal`] (SPEC §2.2).
#[derive(Clone, Debug, Default)]
pub struct MemoryJournal {
entries: Vec<JournalEntry>,
records: Vec<JournalRecord>,
}
impl MemoryJournal {
/// The entries recorded so far, in order.
pub fn entries(&self) -> &[JournalEntry] {
&self.entries
/// The records so far, in order.
pub fn records(&self) -> &[JournalRecord] {
&self.records
}
}
impl Journal for MemoryJournal {
fn append(&mut self, entry: &JournalEntry) -> io::Result<()> {
self.entries.push(entry.clone());
fn append(&mut self, record: &JournalRecord) -> io::Result<()> {
self.records.push(record.clone());
Ok(())
}
}
/// A journal in an append-only file: one JSON entry per line, each synced to
/// A journal in an append-only file: one JSON record per line, each synced to
/// disk before [`append`](Journal::append) returns.
pub struct FileJournal {
file: File,
@ -83,16 +115,16 @@ pub struct FileJournal {
impl FileJournal {
/// Open the journal at `path`, creating it if absent. Returns it with the
/// entries it already holds, for
/// records it already holds, for
/// [`Mesh::with_journal`](crate::Mesh::with_journal) to replay.
///
/// A final line with no newline is an append the steward was interrupted
/// in. It is dropped and cut from the file, which is safe either way: an
/// authorisation that was never recorded never started a serve, and a debit
/// that was never recorded leaves its serve authorised and undebited, which
/// resolves as [`Unresolved`]. Any other line that does not parse is an
/// error, because a journal that cannot be read cannot re-establish spend.
pub fn open(path: impl AsRef<Path>) -> io::Result<(Self, Vec<JournalEntry>)> {
/// in. It is dropped and cut from the file, which is safe for every entry:
/// an authorisation never recorded never started its serve, a debit never
/// recorded leaves its serve [`Unresolved`], and a seal never recorded was
/// never handed out. Any other line that does not parse is an error,
/// because a journal that cannot be read cannot re-establish spend.
pub fn open(path: impl AsRef<Path>) -> io::Result<(Self, Vec<JournalRecord>)> {
let mut file = OpenOptions::new().read(true).append(true).create(true).open(path)?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
@ -101,23 +133,23 @@ impl FileJournal {
file.set_len(complete as u64)?;
file.sync_data()?;
}
let mut entries = Vec::new();
let mut records = Vec::new();
for (n, line) in bytes[..complete].split(|&b| b == b'\n').enumerate() {
if line.is_empty() {
continue;
}
let entry = serde_json::from_slice(line).map_err(|e| {
let record = serde_json::from_slice(line).map_err(|e| {
io::Error::new(io::ErrorKind::InvalidData, format!("journal line {}: {e}", n + 1))
})?;
entries.push(entry);
records.push(record);
}
Ok((FileJournal { file }, entries))
Ok((FileJournal { file }, records))
}
}
impl Journal for FileJournal {
fn append(&mut self, entry: &JournalEntry) -> io::Result<()> {
let mut line = serde_json::to_vec(entry)?;
fn append(&mut self, record: &JournalRecord) -> io::Result<()> {
let mut line = serde_json::to_vec(record)?;
line.push(b'\n');
self.file.write_all(&line)?;
self.file.sync_data()

View file

@ -39,7 +39,7 @@ use jcp_schema::{Caveat, ContentId, DenyReason, Did, Frame, FrameKind, SignedGra
mod journal;
mod presentation;
pub use journal::{FileJournal, Journal, JournalEntry, MemoryJournal, Unresolved};
pub use journal::{FileJournal, Journal, JournalEntry, JournalRecord, MemoryJournal, Unresolved};
pub use presentation::Presentation;
use mesh_engine::ServingEngine;
use mesh_meter::{Meter, Session};
@ -87,10 +87,24 @@ pub enum MeshError {
/// on; or, after the work, its debit could not be.
#[error("journal: {0}")]
Journal(String),
/// A journal being replayed holds an entry the gate would not have
/// allowed, or a debit for a serve it never authorised.
#[error("journal entry {entry} is inconsistent: {reason}")]
/// A journal being replayed is not one this steward produced: its records
/// do not form one chain, or it holds an entry the gate would not have
/// allowed, a debit for a serve it never authorised, or a receipt out of
/// sequence.
#[error("journal record {entry} is inconsistent: {reason}")]
JournalInconsistent { entry: usize, reason: String },
/// A receipt this steward sealed is not in its journal, so records were cut
/// from the journal after the receipt was handed out, and the spend
/// replayed from it is less than the steward signed for. The steward
/// refuses every grant from then on (SPEC §2.2).
#[error(
"the journal holds {holds} receipt(s), but this steward sealed receipt {witnessed}: \
records were cut from it, so it refuses grants"
)]
JournalCut { holds: u64, witnessed: u64 },
/// A receipt shown as a witness is not one this steward sealed.
#[error("not a receipt this steward sealed: {0}")]
WitnessInvalid(String),
/// The engine reported running other weights than the ones it named
/// before serving, which are the ones the grant was decided over.
#[error("backend `{backend}` named model {named} before serving but reported {reported}")]
@ -145,8 +159,16 @@ pub struct Mesh<'m> {
/// The steward's identity as a grant holder: the subject a grant must name
/// to be spent here.
steward_did: Did,
/// Where each grant decision is recorded before its serve runs.
/// Where each grant decision is recorded before its serve runs, and each
/// receipt before it is handed out.
journal: Box<dyn Journal>,
/// The head of every journal record, by its position.
journal_heads: Vec<ContentId>,
/// The envelope id of every receipt the journal holds, by sequence.
sealed: Vec<ContentId>,
/// Set once a witness shows the journal was cut: how many receipts it
/// holds, and the sequence of the receipt that proved it.
cut: Option<(u64, u64)>,
/// Presentations already spent here, by leaf and nonce.
spent_presentations: HashSet<(ContentId, [u8; 16])>,
/// Serves a replayed journal showed in flight when the steward stopped.
@ -170,27 +192,35 @@ impl<'m> Mesh<'m> {
grants: Runtime::new(),
steward_did,
journal: Box::new(MemoryJournal::default()),
journal_heads: Vec::new(),
sealed: Vec::new(),
cut: None,
spent_presentations: HashSet::new(),
unresolved: Vec::new(),
clock: system_clock,
}
}
/// Record grant decisions in `journal`, first re-establishing the spend
/// from `recorded`, the entries it already holds, by replaying each
/// decision through the gate (SPEC §2.2, JCP §4.11). Call it on a new mesh,
/// before serving.
/// Record grant decisions and seals in `journal`, first re-establishing
/// the spend and the receipt chain from `recorded`, the records it already
/// holds, by replaying each through the gate (SPEC §2.2, JCP §4.11). Call
/// it on a new mesh, before serving.
///
/// A replayed authorisation must still verify and be allowed, and a debit
/// must belong to a serve the journal authorised; otherwise the journal is
/// The records must form one chain. A replayed authorisation must still
/// verify and be allowed, a debit must belong to a serve the journal
/// authorised, and seals must run in sequence; otherwise the journal is
/// inconsistent and the mesh is not built. A serve authorised and never
/// debited was in flight when the steward stopped: it is debited at its
/// request's ceiling and listed in [`unresolved`](Self::unresolved).
pub fn with_journal(mut self, journal: impl Journal + 'static, recorded: &[JournalEntry]) -> Result<Self, MeshError> {
/// request's ceiling and listed in [`unresolved`](Self::unresolved). The
/// next receipt continues the chain from the last seal.
pub fn with_journal(mut self, journal: impl Journal + 'static, recorded: &[JournalRecord]) -> Result<Self, MeshError> {
let inconsistent = |entry: usize, reason: String| MeshError::JournalInconsistent { entry, reason };
let mut open: Vec<(&Presentation, &ServeRequest, &str)> = Vec::new();
for (i, entry) in recorded.iter().enumerate() {
match entry {
for (i, record) in recorded.iter().enumerate() {
if record.seq != i as u64 || record.prev != self.journal_heads.last().copied() {
return Err(inconsistent(i, "the records do not form one chain".into()));
}
match &record.entry {
JournalEntry::Authorised { presentation, request, model, backend, now } => {
presentation.verify(request).map_err(|r| inconsistent(i, r))?;
if self.spent_presentations.contains(&presentation.spend_key()) {
@ -209,7 +239,14 @@ impl<'m> Mesh<'m> {
let (p, ..) = open.remove(at);
self.grants.charge_chained(&p.chain, jcp_energy(energy));
}
JournalEntry::Sealed { sequence, envelope } => {
if *sequence != self.sealed.len() as u64 {
return Err(inconsistent(i, "a receipt out of sequence".into()));
}
self.sealed.push(*envelope);
}
}
self.journal_heads.push(record.head());
}
for (p, request, backend) in open {
let ceiling = request.joule_ceiling_uj;
@ -222,10 +259,42 @@ impl<'m> Mesh<'m> {
debited_uj: ceiling,
});
}
self.log.resume(self.sealed.len() as u64, self.sealed.last().copied());
self.journal = Box::new(journal);
Ok(self)
}
/// Show the steward a receipt it sealed, with its envelope. The journal
/// records every receipt before it is handed out, so a receipt the journal
/// does not hold proves records were cut from it after that receipt left:
/// the spend replayed from it is less than the steward signed for. The
/// steward then refuses every grant (SPEC §2.2). Anyone holding a receipt
/// from this steward can show it; a caller SHOULD show its latest after
/// the steward restarts.
pub fn witness(&mut self, receipt: &MeshReceipt, envelope: &[u8]) -> Result<(), MeshError> {
receipt
.verify_envelope(envelope, self.log.steward(), &self.log.verifying_key())
.map_err(|e| MeshError::WitnessInvalid(e.to_string()))?;
if self.sealed.get(receipt.sequence as usize) == Some(&ContentId::of_bytes(envelope)) {
return Ok(());
}
let cut = (self.sealed.len() as u64, receipt.sequence);
self.cut = Some(cut);
Err(MeshError::JournalCut { holds: cut.0, witnessed: cut.1 })
}
/// Append `entry` to the journal as the next record of its chain.
fn record(&mut self, entry: JournalEntry) -> Result<(), MeshError> {
let record = JournalRecord {
seq: self.journal_heads.len() as u64,
prev: self.journal_heads.last().copied(),
entry,
};
self.journal.append(&record).map_err(|e| MeshError::Journal(e.to_string()))?;
self.journal_heads.push(record.head());
Ok(())
}
/// Serves a replayed journal showed in flight when the steward stopped
/// (JCP §4.11): each stays debited at its request's ceiling, and none is
/// retried.
@ -326,6 +395,9 @@ impl<'m> Mesh<'m> {
// presentation for this request, and the presentation must not have
// been spent here before.
if let Some(p) = grant {
if let Some((holds, witnessed)) = self.cut {
return Err(MeshError::JournalCut { holds, witnessed });
}
p.verify(req).map_err(MeshError::CallerNotAuthenticated)?;
if self.spent_presentations.contains(&p.spend_key()) {
return Err(MeshError::Replayed);
@ -367,15 +439,13 @@ impl<'m> Mesh<'m> {
Some(p) => {
let now = (self.clock)();
let id = self.authorize(req, p, model, now)?;
self.journal
.append(&JournalEntry::Authorised {
presentation: p.clone(),
request: req.clone(),
model,
backend: backend.0.clone(),
now,
})
.map_err(|e| MeshError::Journal(e.to_string()))?;
self.record(JournalEntry::Authorised {
presentation: p.clone(),
request: req.clone(),
model,
backend: backend.0.clone(),
now,
})?;
self.spent_presentations.insert(p.spend_key());
Some(id)
}
@ -406,9 +476,7 @@ impl<'m> Mesh<'m> {
Some(p) => {
let d = self.grants.charge_chained(&p.chain, jcp_energy(&energy));
let (grant_id, nonce) = p.spend_key();
self.journal
.append(&JournalEntry::Debited { grant_id, nonce, energy })
.map_err(|e| MeshError::Journal(e.to_string()))?;
self.record(JournalEntry::Debited { grant_id, nonce, energy })?;
match d.verdict {
Verdict::Allow => None,
Verdict::Deny(r) => Some(r.label().to_string()),
@ -489,7 +557,13 @@ impl<'m> Mesh<'m> {
prev: None, // stamped by the log
nonce,
};
let (receipt, envelope) = self.log.append(receipt)?;
// Recorded before it is handed out, so every receipt that leaves the
// steward is in the journal, and one that is not proves a cut.
let (receipt, envelope) = self.log.seal_next(receipt)?;
let id = ContentId::of_bytes(&envelope);
self.record(JournalEntry::Sealed { sequence: receipt.sequence, envelope: id })?;
self.sealed.push(id);
self.log.advance(envelope.clone());
Ok(ServeResponse {
route,
@ -920,11 +994,11 @@ mod tests {
/// A journal whose entries the test reads after the mesh is gone.
#[derive(Clone, Default)]
struct SharedJournal(std::rc::Rc<std::cell::RefCell<Vec<JournalEntry>>>);
struct SharedJournal(std::rc::Rc<std::cell::RefCell<Vec<JournalRecord>>>);
impl Journal for SharedJournal {
fn append(&mut self, entry: &JournalEntry) -> std::io::Result<()> {
self.0.borrow_mut().push(entry.clone());
fn append(&mut self, record: &JournalRecord) -> std::io::Result<()> {
self.0.borrow_mut().push(record.clone());
Ok(())
}
}
@ -945,7 +1019,7 @@ mod tests {
mesh.serve(&req, &[], Some(&present(&sg, &req))).unwrap();
}
let recorded = journal.0.borrow().clone();
assert_eq!(recorded.len(), 4, "two authorisations and two debits");
assert_eq!(recorded.len(), 6, "two authorisations, two debits and two seals");
let mut mesh = mesh_with(&meter).with_journal(journal.clone(), &recorded).unwrap();
assert_eq!(mesh.spent(&sg.grant.id()), 6_000_000, "the restart gave no budget back");
@ -992,10 +1066,10 @@ mod tests {
before.serve(&req, &[], Some(&present(&sg, &req))).unwrap();
drop(before);
let recorded = journal.0.borrow().clone();
let resume = |entries: &[JournalEntry]| mesh_with(&meter).with_journal(MemoryJournal::default(), entries).err();
let resume = |records: &[JournalRecord]| mesh_with(&meter).with_journal(MemoryJournal::default(), records).err();
let mut forged = recorded.clone();
if let JournalEntry::Authorised { presentation, .. } = &mut forged[0] {
if let JournalEntry::Authorised { presentation, .. } = &mut forged[0].entry {
presentation.nonce[15] ^= 1;
}
assert!(matches!(resume(&forged), Some(MeshError::JournalInconsistent { entry: 0, .. })));
@ -1028,7 +1102,7 @@ mod tests {
drop(file);
let (journal, recorded) = FileJournal::open(&path).unwrap();
assert_eq!(recorded.len(), 2);
assert_eq!(recorded.len(), 3);
assert!(std::fs::read(&path).unwrap().ends_with(b"\n"), "the torn line was cut");
let mesh = mesh_with(&meter).with_journal(journal, &recorded).unwrap();
assert_eq!(mesh.spent(&sg.grant.id()), 1_000);
@ -1036,6 +1110,111 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
/// The journal records every receipt before it is handed out, so a
/// receipt the journal does not hold proves records were cut after it
/// left. Shown one, the steward refuses grants: the spend it replayed is
/// less than it signed for. Requests that ride on no grant still serve.
#[test]
fn a_receipt_proves_a_cut_journal() {
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 3_000_000);
let req = request(8, Fidelity::F1, 4_000_000);
let sg = grant_over(&req, 10_000_000, 0, vec![]);
let journal = SharedJournal::default();
let mut before = mesh_with(&meter).with_journal(journal.clone(), &[]).unwrap();
let first = before.serve(&req, &[], Some(&present(&sg, &req))).unwrap();
let cut_away = present(&sg, &req);
let second = before.serve(&req, &[], Some(&cut_away)).unwrap();
drop(before);
let recorded = journal.0.borrow().clone();
// The last serve's three records are cut: the chain that is left is
// whole, so the replay alone cannot tell, and the grant has 3 J back.
let mut mesh = mesh_with(&meter).with_journal(MemoryJournal::default(), &recorded[..3]).unwrap();
assert_eq!(mesh.spent(&sg.grant.id()), 3_000_000);
mesh.witness(&first.receipt, &first.envelope).expect("the journal holds the first receipt");
assert!(matches!(
mesh.witness(&second.receipt, &second.envelope),
Err(MeshError::JournalCut { holds: 1, witnessed: 1 })
));
assert!(matches!(mesh.serve(&req, &[], Some(&cut_away)), Err(MeshError::JournalCut { .. })));
assert!(matches!(mesh.serve(&req, &[], Some(&present(&sg, &req))), Err(MeshError::JournalCut { .. })));
mesh.serve(&req, &[], None).expect("a request on no grant spends no grant");
}
/// After a restart the receipt chain continues from the journal: the next
/// receipt takes the next sequence and chains to the last envelope, rather
/// than starting a new chain at zero.
#[test]
fn the_receipt_chain_continues_across_a_restart() {
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000);
let req = request(8, Fidelity::F1, 4_000_000);
let journal = SharedJournal::default();
let mut before = mesh_with(&meter).with_journal(journal.clone(), &[]).unwrap();
before.serve(&req, &[], None).unwrap();
let last = before.serve(&req, &[], Some(&present(&grant_over(&req, 10_000_000, 0, vec![]), &req))).unwrap();
drop(before);
let recorded = journal.0.borrow().clone();
let mut mesh = mesh_with(&meter).with_journal(journal.clone(), &recorded).unwrap();
mesh.witness(&last.receipt, &last.envelope).expect("the journal holds it");
let next = mesh.serve(&req, &[], None).unwrap();
assert_eq!(next.receipt.sequence, 2);
assert_eq!(next.receipt.prev, Some(ContentId::of_bytes(&last.envelope)));
assert_eq!(mesh.head(), Some(ContentId::of_bytes(&next.envelope)));
}
/// The records form one chain, so a record removed from the middle, or
/// edited, breaks it: only a cut from the end needs a witness.
#[test]
fn a_record_removed_from_the_middle_breaks_the_chain() {
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000);
let req = request(8, Fidelity::F1, 4_000_000);
let sg = grant_over(&req, 10_000_000, 0, vec![]);
let journal = SharedJournal::default();
let mut before = mesh_with(&meter).with_journal(journal.clone(), &[]).unwrap();
before.serve(&req, &[], Some(&present(&sg, &req))).unwrap();
before.serve(&req, &[], None).unwrap();
drop(before);
let recorded = journal.0.borrow().clone();
let resume = |records: &[JournalRecord]| mesh_with(&meter).with_journal(MemoryJournal::default(), records).err();
let mut gapped = recorded.clone();
gapped.remove(1);
assert!(matches!(resume(&gapped), Some(MeshError::JournalInconsistent { entry: 1, .. })));
let mut renumbered = gapped.clone();
for (i, r) in renumbered.iter_mut().enumerate() {
r.seq = i as u64;
}
assert!(matches!(resume(&renumbered), Some(MeshError::JournalInconsistent { entry: 1, .. })));
let mut edited = recorded.clone();
if let JournalEntry::Debited { energy, .. } = &mut edited[1].entry {
energy.estimated_uj = 1;
energy.measured_uj = Some(1);
}
assert!(matches!(resume(&edited), Some(MeshError::JournalInconsistent { entry: 2, .. })));
assert!(resume(&recorded).is_none());
}
/// A witness must be a receipt this steward sealed, unaltered.
#[test]
fn a_witness_must_be_this_stewards_receipt() {
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000);
let req = request(8, Fidelity::F1, 4_000_000);
let mut mesh = mesh_with(&meter);
let resp = mesh.serve(&req, &[], None).unwrap();
let mut other = Mesh::new(&meter, SigningKey::from_bytes(&[12u8; 32]), "did:example:mesh-steward");
other.register(
BackendSpec::new("tiny", "ssm-1b", Fidelity::F1, 50_000),
Box::new(ReferenceEngine::new("tiny", "ssm-1b", Fidelity::F1, 50_000, 256)),
);
let foreign = other.serve(&req, &[], None).unwrap();
assert!(matches!(mesh.witness(&foreign.receipt, &foreign.envelope), Err(MeshError::WitnessInvalid(_))));
let mut altered = resp.receipt.clone();
altered.sequence = 7;
assert!(matches!(mesh.witness(&altered, &resp.envelope), Err(MeshError::WitnessInvalid(_))));
mesh.witness(&resp.receipt, &resp.envelope).expect("its own receipt");
}
#[test]
fn measured_end_to_end_seals_and_verifies() {
// A HwShunt stepping meter yields a deterministic 3_200_000 µJ span,

View file

@ -308,14 +308,41 @@ impl ReceiptLog {
/// Chain a receipt: stamp `sequence` + `prev`, seal, advance the head.
/// Returns the stamped receipt and its sealed envelope.
pub fn append(&mut self, mut receipt: MeshReceipt) -> Result<(MeshReceipt, Vec<u8>), ReceiptError> {
pub fn append(&mut self, receipt: MeshReceipt) -> Result<(MeshReceipt, Vec<u8>), ReceiptError> {
let (receipt, envelope) = self.seal_next(receipt)?;
self.advance(envelope.clone());
Ok((receipt, envelope))
}
/// Stamp and seal the next receipt without advancing the log, so a
/// steward can record it durably first and [`advance`](Self::advance)
/// only once it has. Sealing the same receipt again gives the same bytes.
pub fn seal_next(&self, mut receipt: MeshReceipt) -> Result<(MeshReceipt, Vec<u8>), ReceiptError> {
receipt.sequence = self.seq;
receipt.prev = self.head;
let envelope = receipt.seal(&self.steward_sk, &self.steward)?;
Ok((receipt, envelope))
}
/// Advance past an envelope [`seal_next`](Self::seal_next) produced.
pub fn advance(&mut self, envelope: Vec<u8>) {
self.head = Some(ContentId::of_bytes(&envelope));
self.seq += 1;
self.entries.push(envelope.clone());
Ok((receipt, envelope))
self.entries.push(envelope);
}
/// Continue a chain an earlier process sealed: the next receipt takes
/// `next_sequence` and chains to `head`, the content id of the last
/// envelope sealed. Envelopes sealed before are not in
/// [`entries`](Self::entries); they were handed out when sealed.
pub fn resume(&mut self, next_sequence: u64, head: Option<ContentId>) {
self.seq = next_sequence;
self.head = head;
}
/// The sequence the next receipt will take.
pub fn next_sequence(&self) -> u64 {
self.seq
}
}