1use std::collections::HashMap;
21
22use serde::{Deserialize, Serialize};
23use sha2::{Digest, Sha256};
24
25use crate::error::{Result, SdkError};
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30#[serde(tag = "event", rename_all = "snake_case")]
31pub enum LedgerEvent {
32 ProposalObserved {
33 proposal_id: String,
34 actor: String,
35 },
36 AdmissibilityChecked {
37 proposal_id: String,
38 allowed: bool,
39 },
40 EffectApplied {
41 proposal_id: String,
42 idempotency_key: String,
43 },
44 EffectDenied {
45 proposal_id: String,
46 reason: String,
47 },
48 VerifierCompleted {
49 node_id: String,
50 generation: u32,
51 },
52 ResidualEmitted {
53 residual_id: String,
54 node_id: String,
55 },
56 EnergyScored {
57 node_id: String,
58 generation: u32,
59 energy: f64,
60 },
61 GateDecisionRecorded {
62 node_id: String,
63 accepted: bool,
64 },
65 CandidateAccepted {
66 node_id: String,
67 generation: u32,
68 energy: f64,
69 },
70 CandidateRejected {
71 node_id: String,
72 generation: u32,
73 },
74 GraphRevisionAccepted {
75 revision_id: String,
76 sequence: u32,
77 },
78 NodeGenerationRetired {
79 node_id: String,
80 generation: u32,
81 },
82 ResidualCertificateIssued {
83 certificate_id: String,
84 node_id: String,
85 },
86 RollbackApplied {
87 target_event: String,
88 },
89 CapabilityGranted {
90 capability_id: String,
91 holder: String,
92 },
93 CapabilityRevoked {
94 capability_id: String,
95 },
96 ObservationRecorded {
98 handle: String,
99 content_hash: String,
100 },
101 RouteResolved {
103 phase: String,
104 model: String,
105 reason: String,
106 },
107 ProviderDegraded {
108 provider: String,
109 missing: String,
110 fallback: String,
111 },
112 RouteFailover {
113 from_model: String,
114 to_model: String,
115 cause: String,
116 },
117 VerdictRecorded {
118 validator: String,
119 candidate_id: String,
120 passed: bool,
121 },
122 Custom {
123 kind: String,
124 payload: serde_json::Value,
125 },
126}
127
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130pub struct LedgerRecord {
131 pub sequence: u64,
132 pub event: LedgerEvent,
133 pub prev_hash: String,
135 pub hash: String,
137}
138
139pub fn entry_chain_hash(prev_hash: &str, sequence: u64, event: &LedgerEvent) -> Result<String> {
145 chain_hash(prev_hash, sequence, event)
146}
147
148fn chain_hash(prev_hash: &str, sequence: u64, event: &LedgerEvent) -> Result<String> {
149 let canonical = serde_json::to_vec(event)
150 .map_err(|e| SdkError::Domain(format!("event serialization failed: {e}")))?;
151 let mut hasher = Sha256::new();
152 hasher.update(prev_hash.as_bytes());
153 hasher.update(sequence.to_le_bytes());
154 hasher.update(&canonical);
155 Ok(hex(&hasher.finalize()))
156}
157
158pub fn content_hash(bytes: &[u8]) -> String {
160 let mut hasher = Sha256::new();
161 hasher.update(bytes);
162 hex(&hasher.finalize())
163}
164
165fn hex(bytes: &[u8]) -> String {
166 let mut s = String::with_capacity(bytes.len() * 2);
167 for b in bytes {
168 s.push_str(&format!("{b:02x}"));
169 }
170 s
171}
172
173#[derive(Debug, Clone, Default)]
175pub struct Ledger {
176 records: Vec<LedgerRecord>,
177 observations: HashMap<String, String>,
179}
180
181impl Ledger {
182 pub fn new() -> Self {
183 Self::default()
184 }
185
186 pub fn head(&self) -> String {
188 self.records
189 .last()
190 .map(|r| r.hash.clone())
191 .unwrap_or_else(|| "GENESIS".to_string())
192 }
193
194 pub fn len(&self) -> usize {
195 self.records.len()
196 }
197
198 pub fn is_empty(&self) -> bool {
199 self.records.is_empty()
200 }
201
202 pub fn records(&self) -> &[LedgerRecord] {
203 &self.records
204 }
205
206 pub fn append(&mut self, event: LedgerEvent) -> Result<String> {
208 let sequence = self.records.len() as u64;
209 let prev_hash = self.head();
210 let hash = chain_hash(&prev_hash, sequence, &event)?;
211 if let LedgerEvent::ObservationRecorded {
213 handle,
214 content_hash,
215 } = &event
216 {
217 self.observations
218 .insert(handle.clone(), content_hash.clone());
219 }
220 self.records.push(LedgerRecord {
221 sequence,
222 event,
223 prev_hash,
224 hash: hash.clone(),
225 });
226 Ok(hash)
227 }
228
229 pub fn stage(&self, event: LedgerEvent) -> Result<LedgerRecord> {
235 let sequence = self.records.len() as u64;
236 let prev_hash = self.head();
237 let hash = chain_hash(&prev_hash, sequence, &event)?;
238 Ok(LedgerRecord {
239 sequence,
240 event,
241 prev_hash,
242 hash,
243 })
244 }
245
246 pub fn commit_staged(&mut self, record: LedgerRecord) -> Result<()> {
249 if record.sequence != self.records.len() as u64 || record.prev_hash != self.head() {
250 return Err(SdkError::Domain(
251 "staged record does not extend the current ledger head".into(),
252 ));
253 }
254 if let LedgerEvent::ObservationRecorded {
255 handle,
256 content_hash,
257 } = &record.event
258 {
259 self.observations
260 .insert(handle.clone(), content_hash.clone());
261 }
262 self.records.push(record);
263 Ok(())
264 }
265
266 pub fn record_observation(&mut self, content: &[u8]) -> Result<String> {
269 let content_hash = content_hash(content);
270 let handle = content_hash.clone();
271 self.append(LedgerEvent::ObservationRecorded {
272 handle: handle.clone(),
273 content_hash,
274 })?;
275 Ok(handle)
276 }
277
278 pub fn has_observation(&self, handle: &str) -> bool {
280 self.observations.contains_key(handle)
281 }
282
283 pub fn observation_hash(&self, handle: &str) -> Option<&str> {
285 self.observations.get(handle).map(String::as_str)
286 }
287
288 pub fn commit_transition(
292 &mut self,
293 event: LedgerEvent,
294 referenced_observations: &[String],
295 ) -> Result<String> {
296 for handle in referenced_observations {
297 if !self.has_observation(handle) {
298 return Err(SdkError::Domain(format!(
299 "kernel-refusal: transition references unrecorded observation `{handle}`"
300 )));
301 }
302 }
303 self.append(event)
304 }
305
306 pub fn verify_chain(&self) -> Result<()> {
308 let mut prev = "GENESIS".to_string();
309 for (i, rec) in self.records.iter().enumerate() {
310 if rec.sequence != i as u64 {
311 return Err(SdkError::Domain(format!("sequence gap at index {i}")));
312 }
313 if rec.prev_hash != prev {
314 return Err(SdkError::Domain(format!(
315 "broken chain at sequence {}",
316 rec.sequence
317 )));
318 }
319 let expected = chain_hash(&rec.prev_hash, rec.sequence, &rec.event)?;
320 if expected != rec.hash {
321 return Err(SdkError::Domain(format!(
322 "hash mismatch at sequence {}",
323 rec.sequence
324 )));
325 }
326 prev = rec.hash.clone();
327 }
328 Ok(())
329 }
330}
331
332#[derive(Debug, Clone, Copy, PartialEq, Eq)]
340pub enum ToolLoopBody<'a> {
341 Legacy(&'a serde_json::Value),
342 V1(&'a serde_json::Value),
343}
344
345pub fn tool_loop_body(payload: &serde_json::Value) -> Result<ToolLoopBody<'_>> {
347 match payload.get("schema_version") {
348 None => Ok(ToolLoopBody::Legacy(payload)),
349 Some(version) if version.as_u64() == Some(1) => payload
350 .get("body")
351 .map(ToolLoopBody::V1)
352 .ok_or_else(|| SdkError::Domain("versioned tool_loop row has no body".into())),
353 Some(version) => Err(SdkError::Domain(format!(
354 "unknown tool_loop schema_version {version}; authoritative replay fails closed"
355 ))),
356 }
357}
358
359pub fn replay_accepted_trajectory(ledger: &Ledger) -> Result<Vec<(String, u32, f64)>> {
363 let typed: Vec<(String, u32, f64)> = ledger
368 .records()
369 .iter()
370 .filter_map(|record| match &record.event {
371 LedgerEvent::CandidateAccepted {
372 node_id,
373 generation,
374 energy,
375 } => Some((node_id.clone(), *generation, *energy)),
376 _ => None,
377 })
378 .collect();
379 if !typed.is_empty() {
380 return Ok(typed);
381 }
382
383 let mut accepted = Vec::new();
384 let mut latest = HashMap::<(String, u32, String), f64>::new();
390 for record in ledger.records() {
391 if let LedgerEvent::Custom { kind, payload } = &record.event {
392 if kind == "tool_loop" {
393 let (ToolLoopBody::Legacy(body) | ToolLoopBody::V1(body)) =
395 tool_loop_body(payload)?;
396 fold_tool_loop_row(body, &mut latest, &mut accepted);
397 }
398 }
399 }
400 Ok(accepted)
401}
402
403fn fold_tool_loop_row(
405 payload: &serde_json::Value,
406 latest: &mut HashMap<(String, u32, String), f64>,
407 accepted: &mut Vec<(String, u32, f64)>,
408) {
409 let event = payload.get("event").and_then(|value| value.as_str());
410 let node = payload.get("node_id").and_then(|value| value.as_str());
411 let generation = payload
412 .get("generation")
413 .and_then(|value| value.as_u64())
414 .and_then(|value| u32::try_from(value).ok());
415 let candidate = payload
416 .get("candidate_id")
417 .and_then(|value| value.as_str())
418 .unwrap_or("");
419 if let (Some("candidate_measured"), Some(node), Some(generation), Some(energy)) = (
420 event,
421 node,
422 generation,
423 payload.get("energy").and_then(|value| value.as_f64()),
424 ) {
425 latest.insert(
426 (node.to_string(), generation, candidate.to_string()),
427 energy,
428 );
429 }
430 if let (Some("gate_decision_recorded"), Some(node), Some(generation)) =
431 (event, node, generation)
432 {
433 let accepted_decision = payload
437 .get("decision")
438 .cloned()
439 .and_then(|value| serde_json::from_value::<crate::gate::GateDecision>(value).ok())
440 .is_some_and(|decision| decision.is_accepted());
441 if accepted_decision {
442 let energy = payload
445 .get("observed_energy")
446 .and_then(|value| value.as_f64())
447 .or_else(|| {
448 latest
449 .get(&(node.to_string(), generation, candidate.to_string()))
450 .copied()
451 });
452 if let Some(energy) = energy {
453 accepted.push((node.to_string(), generation, energy));
454 }
455 }
456 }
457}
458
459#[derive(Debug, Clone, Default)]
464pub struct IdempotencyLog {
465 entries: HashMap<String, (String, String)>, }
467
468impl IdempotencyLog {
469 pub fn new() -> Self {
470 Self::default()
471 }
472
473 pub fn record(&mut self, key: &str, content: &[u8], outcome: &str) -> Result<String> {
477 let ch = content_hash(content);
478 match self.entries.get(key) {
479 Some((existing_hash, existing_outcome)) => {
480 if existing_hash == &ch {
481 Ok(existing_outcome.clone())
482 } else {
483 Err(SdkError::Domain(format!(
484 "idempotency key `{key}` reused for different content"
485 )))
486 }
487 }
488 None => {
489 self.entries
490 .insert(key.to_string(), (ch, outcome.to_string()));
491 Ok(outcome.to_string())
492 }
493 }
494 }
495}
496
497pub fn require_recorded(ledger: &Ledger, handles: &[String]) -> Result<Vec<String>> {
502 let mut hashes = Vec::with_capacity(handles.len());
503 for handle in handles {
504 match ledger.observation_hash(handle) {
505 Some(hash) => hashes.push(hash.to_string()),
506 None => {
507 return Err(SdkError::Domain(format!(
508 "transition depends on unrecorded observation {handle:?}; \
509 record it before use (R2)"
510 )))
511 }
512 }
513 }
514 Ok(hashes)
515}
516
517#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
524pub struct AuditReport {
525 pub chain_ok: bool,
526 pub head: String,
527 pub records: u64,
528 pub accepted: Vec<(String, u32, f64)>,
530}
531
532pub fn audit_replay(ledger: &Ledger) -> AuditReport {
535 let chain_ok = ledger.verify_chain().is_ok();
536 let accepted = replay_accepted_trajectory(ledger);
539 AuditReport {
540 chain_ok: chain_ok && accepted.is_ok(),
541 head: ledger.head(),
542 records: ledger.records().len() as u64,
543 accepted: accepted.unwrap_or_default(),
544 }
545}
546
547#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
551pub enum ExternalEffectPhase {
552 Intent,
553 Result,
554 Compensation,
555}
556
557#[derive(Debug, Clone, Default)]
558pub struct ExternalEffectLog {
559 phases: HashMap<String, Vec<ExternalEffectPhase>>, }
561
562impl ExternalEffectLog {
563 pub fn new() -> Self {
564 Self::default()
565 }
566
567 pub fn intent(&mut self, key: &str) {
569 self.phases
570 .entry(key.to_string())
571 .or_default()
572 .push(ExternalEffectPhase::Intent);
573 }
574
575 pub fn result(&mut self, key: &str) -> Result<()> {
577 let phases = self.phases.get(key).cloned().unwrap_or_default();
578 if !phases.contains(&ExternalEffectPhase::Intent) {
579 return Err(SdkError::Domain(format!(
580 "R5 violation: result recorded for `{key}` without prior intent"
581 )));
582 }
583 self.phases
584 .get_mut(key)
585 .unwrap()
586 .push(ExternalEffectPhase::Result);
587 Ok(())
588 }
589
590 pub fn compensation(&mut self, key: &str) {
591 self.phases
592 .entry(key.to_string())
593 .or_default()
594 .push(ExternalEffectPhase::Compensation);
595 }
596
597 pub fn is_bracketed(&self, key: &str) -> bool {
599 match self.phases.get(key) {
600 Some(p) => {
601 let i = p.iter().position(|x| *x == ExternalEffectPhase::Intent);
602 let r = p.iter().position(|x| *x == ExternalEffectPhase::Result);
603 matches!((i, r), (Some(i), Some(r)) if i < r)
604 }
605 None => false,
606 }
607 }
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613
614 #[test]
615 fn chain_is_verifiable() {
616 let mut ledger = Ledger::new();
617 ledger
618 .append(LedgerEvent::CandidateAccepted {
619 node_id: "a".into(),
620 generation: 0,
621 energy: 5.0,
622 })
623 .unwrap();
624 ledger
625 .append(LedgerEvent::CandidateAccepted {
626 node_id: "b".into(),
627 generation: 0,
628 energy: 0.0,
629 })
630 .unwrap();
631 assert_eq!(ledger.len(), 2);
632 assert!(ledger.verify_chain().is_ok());
633 }
634
635 #[test]
636 fn tampering_breaks_the_chain() {
637 let mut ledger = Ledger::new();
638 ledger
639 .append(LedgerEvent::CandidateAccepted {
640 node_id: "a".into(),
641 generation: 0,
642 energy: 5.0,
643 })
644 .unwrap();
645 ledger
646 .append(LedgerEvent::CandidateAccepted {
647 node_id: "b".into(),
648 generation: 0,
649 energy: 0.0,
650 })
651 .unwrap();
652 if let LedgerEvent::CandidateAccepted { energy, .. } = &mut ledger.records[0].event {
654 *energy = 999.0;
655 }
656 assert!(ledger.verify_chain().is_err());
657 }
658
659 #[test]
660 fn replay_reconstructs_accepted_trajectory() {
661 let mut ledger = Ledger::new();
662 ledger
663 .append(LedgerEvent::CandidateRejected {
664 node_id: "a".into(),
665 generation: 0,
666 })
667 .unwrap();
668 ledger
669 .append(LedgerEvent::CandidateAccepted {
670 node_id: "a".into(),
671 generation: 1,
672 energy: 8.0,
673 })
674 .unwrap();
675 ledger
676 .append(LedgerEvent::CandidateAccepted {
677 node_id: "b".into(),
678 generation: 0,
679 energy: 0.0,
680 })
681 .unwrap();
682 let traj = replay_accepted_trajectory(&ledger);
683 assert_eq!(
684 traj.unwrap(),
685 vec![("a".into(), 1, 8.0), ("b".into(), 0, 0.0)]
686 );
687 }
688
689 #[test]
690 fn kernel_refuses_unrecorded_observation() {
691 let mut ledger = Ledger::new();
692 let event = LedgerEvent::EffectApplied {
693 proposal_id: "p1".into(),
694 idempotency_key: "k1".into(),
695 };
696 let err = ledger.commit_transition(event.clone(), &["never-recorded".into()]);
698 assert!(err.is_err());
699
700 let handle = ledger.record_observation(b"llm output bytes").unwrap();
702 assert!(ledger.has_observation(&handle));
703 assert!(ledger.commit_transition(event, &[handle]).is_ok());
704 }
705
706 #[test]
707 fn idempotency_redelivery_returns_prior_outcome() {
708 let mut log = IdempotencyLog::new();
709 let first = log.record("k1", b"patch-content", "applied").unwrap();
710 assert_eq!(first, "applied");
711 let again = log.record("k1", b"patch-content", "applied-again").unwrap();
713 assert_eq!(again, "applied");
714 }
715
716 #[test]
717 fn idempotency_key_reuse_for_different_content_is_invalid() {
718 let mut log = IdempotencyLog::new();
719 log.record("k1", b"content-a", "applied").unwrap();
720 assert!(log.record("k1", b"content-b", "applied").is_err());
721 }
722
723 #[test]
724 fn external_effect_must_be_bracketed() {
725 let mut log = ExternalEffectLog::new();
726 assert!(log.result("k1").is_err());
728 log.intent("k1");
729 assert!(log.result("k1").is_ok());
730 assert!(log.is_bracketed("k1"));
731 }
732}