use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::error::{Result, SdkError};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum LedgerEvent {
ProposalObserved {
proposal_id: String,
actor: String,
},
AdmissibilityChecked {
proposal_id: String,
allowed: bool,
},
EffectApplied {
proposal_id: String,
idempotency_key: String,
},
EffectDenied {
proposal_id: String,
reason: String,
},
VerifierCompleted {
node_id: String,
generation: u32,
},
ResidualEmitted {
residual_id: String,
node_id: String,
},
EnergyScored {
node_id: String,
generation: u32,
energy: f64,
},
GateDecisionRecorded {
node_id: String,
accepted: bool,
},
CandidateAccepted {
node_id: String,
generation: u32,
energy: f64,
},
CandidateRejected {
node_id: String,
generation: u32,
},
GraphRevisionAccepted {
revision_id: String,
sequence: u32,
},
NodeGenerationRetired {
node_id: String,
generation: u32,
},
ResidualCertificateIssued {
certificate_id: String,
node_id: String,
},
RollbackApplied {
target_event: String,
},
CapabilityGranted {
capability_id: String,
holder: String,
},
CapabilityRevoked {
capability_id: String,
},
ObservationRecorded {
handle: String,
content_hash: String,
},
Custom {
kind: String,
payload: serde_json::Value,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LedgerRecord {
pub sequence: u64,
pub event: LedgerEvent,
pub prev_hash: String,
pub hash: String,
}
fn chain_hash(prev_hash: &str, sequence: u64, event: &LedgerEvent) -> Result<String> {
let canonical = serde_json::to_vec(event)
.map_err(|e| SdkError::Domain(format!("event serialization failed: {e}")))?;
let mut hasher = Sha256::new();
hasher.update(prev_hash.as_bytes());
hasher.update(sequence.to_le_bytes());
hasher.update(&canonical);
Ok(hex(&hasher.finalize()))
}
pub fn content_hash(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex(&hasher.finalize())
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
#[derive(Debug, Clone, Default)]
pub struct Ledger {
records: Vec<LedgerRecord>,
observations: HashMap<String, String>,
}
impl Ledger {
pub fn new() -> Self {
Self::default()
}
pub fn head(&self) -> String {
self.records
.last()
.map(|r| r.hash.clone())
.unwrap_or_else(|| "GENESIS".to_string())
}
pub fn len(&self) -> usize {
self.records.len()
}
pub fn is_empty(&self) -> bool {
self.records.is_empty()
}
pub fn records(&self) -> &[LedgerRecord] {
&self.records
}
pub fn append(&mut self, event: LedgerEvent) -> Result<String> {
let sequence = self.records.len() as u64;
let prev_hash = self.head();
let hash = chain_hash(&prev_hash, sequence, &event)?;
if let LedgerEvent::ObservationRecorded {
handle,
content_hash,
} = &event
{
self.observations
.insert(handle.clone(), content_hash.clone());
}
self.records.push(LedgerRecord {
sequence,
event,
prev_hash,
hash: hash.clone(),
});
Ok(hash)
}
pub fn record_observation(&mut self, content: &[u8]) -> Result<String> {
let content_hash = content_hash(content);
let handle = content_hash.clone();
self.append(LedgerEvent::ObservationRecorded {
handle: handle.clone(),
content_hash,
})?;
Ok(handle)
}
pub fn has_observation(&self, handle: &str) -> bool {
self.observations.contains_key(handle)
}
pub fn commit_transition(
&mut self,
event: LedgerEvent,
referenced_observations: &[String],
) -> Result<String> {
for handle in referenced_observations {
if !self.has_observation(handle) {
return Err(SdkError::Domain(format!(
"kernel-refusal: transition references unrecorded observation `{handle}`"
)));
}
}
self.append(event)
}
pub fn verify_chain(&self) -> Result<()> {
let mut prev = "GENESIS".to_string();
for (i, rec) in self.records.iter().enumerate() {
if rec.sequence != i as u64 {
return Err(SdkError::Domain(format!("sequence gap at index {i}")));
}
if rec.prev_hash != prev {
return Err(SdkError::Domain(format!(
"broken chain at sequence {}",
rec.sequence
)));
}
let expected = chain_hash(&rec.prev_hash, rec.sequence, &rec.event)?;
if expected != rec.hash {
return Err(SdkError::Domain(format!(
"hash mismatch at sequence {}",
rec.sequence
)));
}
prev = rec.hash.clone();
}
Ok(())
}
}
pub fn replay_accepted_trajectory(ledger: &Ledger) -> Vec<(String, u32, f64)> {
ledger
.records()
.iter()
.filter_map(|r| match &r.event {
LedgerEvent::CandidateAccepted {
node_id,
generation,
energy,
} => Some((node_id.clone(), *generation, *energy)),
_ => None,
})
.collect()
}
#[derive(Debug, Clone, Default)]
pub struct IdempotencyLog {
entries: HashMap<String, (String, String)>, }
impl IdempotencyLog {
pub fn new() -> Self {
Self::default()
}
pub fn record(&mut self, key: &str, content: &[u8], outcome: &str) -> Result<String> {
let ch = content_hash(content);
match self.entries.get(key) {
Some((existing_hash, existing_outcome)) => {
if existing_hash == &ch {
Ok(existing_outcome.clone())
} else {
Err(SdkError::Domain(format!(
"idempotency key `{key}` reused for different content"
)))
}
}
None => {
self.entries
.insert(key.to_string(), (ch, outcome.to_string()));
Ok(outcome.to_string())
}
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ExternalEffectPhase {
Intent,
Result,
Compensation,
}
#[derive(Debug, Clone, Default)]
pub struct ExternalEffectLog {
phases: HashMap<String, Vec<ExternalEffectPhase>>, }
impl ExternalEffectLog {
pub fn new() -> Self {
Self::default()
}
pub fn intent(&mut self, key: &str) {
self.phases
.entry(key.to_string())
.or_default()
.push(ExternalEffectPhase::Intent);
}
pub fn result(&mut self, key: &str) -> Result<()> {
let phases = self.phases.get(key).cloned().unwrap_or_default();
if !phases.contains(&ExternalEffectPhase::Intent) {
return Err(SdkError::Domain(format!(
"R5 violation: result recorded for `{key}` without prior intent"
)));
}
self.phases
.get_mut(key)
.unwrap()
.push(ExternalEffectPhase::Result);
Ok(())
}
pub fn compensation(&mut self, key: &str) {
self.phases
.entry(key.to_string())
.or_default()
.push(ExternalEffectPhase::Compensation);
}
pub fn is_bracketed(&self, key: &str) -> bool {
match self.phases.get(key) {
Some(p) => {
let i = p.iter().position(|x| *x == ExternalEffectPhase::Intent);
let r = p.iter().position(|x| *x == ExternalEffectPhase::Result);
matches!((i, r), (Some(i), Some(r)) if i < r)
}
None => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chain_is_verifiable() {
let mut ledger = Ledger::new();
ledger
.append(LedgerEvent::CandidateAccepted {
node_id: "a".into(),
generation: 0,
energy: 5.0,
})
.unwrap();
ledger
.append(LedgerEvent::CandidateAccepted {
node_id: "b".into(),
generation: 0,
energy: 0.0,
})
.unwrap();
assert_eq!(ledger.len(), 2);
assert!(ledger.verify_chain().is_ok());
}
#[test]
fn tampering_breaks_the_chain() {
let mut ledger = Ledger::new();
ledger
.append(LedgerEvent::CandidateAccepted {
node_id: "a".into(),
generation: 0,
energy: 5.0,
})
.unwrap();
ledger
.append(LedgerEvent::CandidateAccepted {
node_id: "b".into(),
generation: 0,
energy: 0.0,
})
.unwrap();
if let LedgerEvent::CandidateAccepted { energy, .. } = &mut ledger.records[0].event {
*energy = 999.0;
}
assert!(ledger.verify_chain().is_err());
}
#[test]
fn replay_reconstructs_accepted_trajectory() {
let mut ledger = Ledger::new();
ledger
.append(LedgerEvent::CandidateRejected {
node_id: "a".into(),
generation: 0,
})
.unwrap();
ledger
.append(LedgerEvent::CandidateAccepted {
node_id: "a".into(),
generation: 1,
energy: 8.0,
})
.unwrap();
ledger
.append(LedgerEvent::CandidateAccepted {
node_id: "b".into(),
generation: 0,
energy: 0.0,
})
.unwrap();
let traj = replay_accepted_trajectory(&ledger);
assert_eq!(traj, vec![("a".into(), 1, 8.0), ("b".into(), 0, 0.0)]);
}
#[test]
fn kernel_refuses_unrecorded_observation() {
let mut ledger = Ledger::new();
let event = LedgerEvent::EffectApplied {
proposal_id: "p1".into(),
idempotency_key: "k1".into(),
};
let err = ledger.commit_transition(event.clone(), &["never-recorded".into()]);
assert!(err.is_err());
let handle = ledger.record_observation(b"llm output bytes").unwrap();
assert!(ledger.has_observation(&handle));
assert!(ledger.commit_transition(event, &[handle]).is_ok());
}
#[test]
fn idempotency_redelivery_returns_prior_outcome() {
let mut log = IdempotencyLog::new();
let first = log.record("k1", b"patch-content", "applied").unwrap();
assert_eq!(first, "applied");
let again = log.record("k1", b"patch-content", "applied-again").unwrap();
assert_eq!(again, "applied");
}
#[test]
fn idempotency_key_reuse_for_different_content_is_invalid() {
let mut log = IdempotencyLog::new();
log.record("k1", b"content-a", "applied").unwrap();
assert!(log.record("k1", b"content-b", "applied").is_err());
}
#[test]
fn external_effect_must_be_bracketed() {
let mut log = ExternalEffectLog::new();
assert!(log.result("k1").is_err());
log.intent("k1");
assert!(log.result("k1").is_ok());
assert!(log.is_bracketed("k1"));
}
}