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,
},
RouteResolved {
phase: String,
model: String,
reason: String,
},
ProviderDegraded {
provider: String,
missing: String,
fallback: String,
},
RouteFailover {
from_model: String,
to_model: String,
cause: String,
},
VerdictRecorded {
validator: String,
candidate_id: String,
passed: bool,
},
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,
}
pub fn entry_chain_hash(prev_hash: &str, sequence: u64, event: &LedgerEvent) -> Result<String> {
chain_hash(prev_hash, sequence, event)
}
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 stage(&self, event: LedgerEvent) -> Result<LedgerRecord> {
let sequence = self.records.len() as u64;
let prev_hash = self.head();
let hash = chain_hash(&prev_hash, sequence, &event)?;
Ok(LedgerRecord {
sequence,
event,
prev_hash,
hash,
})
}
pub fn commit_staged(&mut self, record: LedgerRecord) -> Result<()> {
if record.sequence != self.records.len() as u64 || record.prev_hash != self.head() {
return Err(SdkError::Domain(
"staged record does not extend the current ledger head".into(),
));
}
if let LedgerEvent::ObservationRecorded {
handle,
content_hash,
} = &record.event
{
self.observations
.insert(handle.clone(), content_hash.clone());
}
self.records.push(record);
Ok(())
}
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 observation_hash(&self, handle: &str) -> Option<&str> {
self.observations.get(handle).map(String::as_str)
}
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(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolLoopBody<'a> {
Legacy(&'a serde_json::Value),
V1(&'a serde_json::Value),
}
pub fn tool_loop_body(payload: &serde_json::Value) -> Result<ToolLoopBody<'_>> {
match payload.get("schema_version") {
None => Ok(ToolLoopBody::Legacy(payload)),
Some(version) if version.as_u64() == Some(1) => payload
.get("body")
.map(ToolLoopBody::V1)
.ok_or_else(|| SdkError::Domain("versioned tool_loop row has no body".into())),
Some(version) => Err(SdkError::Domain(format!(
"unknown tool_loop schema_version {version}; authoritative replay fails closed"
))),
}
}
pub fn replay_accepted_trajectory(ledger: &Ledger) -> Result<Vec<(String, u32, f64)>> {
let typed: Vec<(String, u32, f64)> = ledger
.records()
.iter()
.filter_map(|record| match &record.event {
LedgerEvent::CandidateAccepted {
node_id,
generation,
energy,
} => Some((node_id.clone(), *generation, *energy)),
_ => None,
})
.collect();
if !typed.is_empty() {
return Ok(typed);
}
let mut accepted = Vec::new();
let mut latest = HashMap::<(String, u32, String), f64>::new();
for record in ledger.records() {
if let LedgerEvent::Custom { kind, payload } = &record.event {
if kind == "tool_loop" {
let (ToolLoopBody::Legacy(body) | ToolLoopBody::V1(body)) =
tool_loop_body(payload)?;
fold_tool_loop_row(body, &mut latest, &mut accepted);
}
}
}
Ok(accepted)
}
fn fold_tool_loop_row(
payload: &serde_json::Value,
latest: &mut HashMap<(String, u32, String), f64>,
accepted: &mut Vec<(String, u32, f64)>,
) {
let event = payload.get("event").and_then(|value| value.as_str());
let node = payload.get("node_id").and_then(|value| value.as_str());
let generation = payload
.get("generation")
.and_then(|value| value.as_u64())
.and_then(|value| u32::try_from(value).ok());
let candidate = payload
.get("candidate_id")
.and_then(|value| value.as_str())
.unwrap_or("");
if let (Some("candidate_measured"), Some(node), Some(generation), Some(energy)) = (
event,
node,
generation,
payload.get("energy").and_then(|value| value.as_f64()),
) {
latest.insert(
(node.to_string(), generation, candidate.to_string()),
energy,
);
}
if let (Some("gate_decision_recorded"), Some(node), Some(generation)) =
(event, node, generation)
{
let accepted_decision = payload
.get("decision")
.cloned()
.and_then(|value| serde_json::from_value::<crate::gate::GateDecision>(value).ok())
.is_some_and(|decision| decision.is_accepted());
if accepted_decision {
let energy = payload
.get("observed_energy")
.and_then(|value| value.as_f64())
.or_else(|| {
latest
.get(&(node.to_string(), generation, candidate.to_string()))
.copied()
});
if let Some(energy) = energy {
accepted.push((node.to_string(), generation, energy));
}
}
}
}
#[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())
}
}
}
}
pub fn require_recorded(ledger: &Ledger, handles: &[String]) -> Result<Vec<String>> {
let mut hashes = Vec::with_capacity(handles.len());
for handle in handles {
match ledger.observation_hash(handle) {
Some(hash) => hashes.push(hash.to_string()),
None => {
return Err(SdkError::Domain(format!(
"transition depends on unrecorded observation {handle:?}; \
record it before use (R2)"
)))
}
}
}
Ok(hashes)
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AuditReport {
pub chain_ok: bool,
pub head: String,
pub records: u64,
pub accepted: Vec<(String, u32, f64)>,
}
pub fn audit_replay(ledger: &Ledger) -> AuditReport {
let chain_ok = ledger.verify_chain().is_ok();
let accepted = replay_accepted_trajectory(ledger);
AuditReport {
chain_ok: chain_ok && accepted.is_ok(),
head: ledger.head(),
records: ledger.records().len() as u64,
accepted: accepted.unwrap_or_default(),
}
}
#[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.unwrap(),
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"));
}
}