1use crate::claims::{detect_conflicts, ClaimConflict};
28use crate::error::{KernelError, Result};
29use crate::policy::KernelPolicy;
30use crate::trace::{PrivacyClass, Trace, TraceKind, TraceStatus};
31use blake3::Hasher;
32use serde::{Deserialize, Serialize};
33use serde_json::value::RawValue;
34use std::collections::{BTreeMap, BTreeSet};
35use std::fs::{self, File, OpenOptions};
36use std::io::{Read, Seek, SeekFrom, Write};
37use std::path::{Path, PathBuf};
38
39const GENESIS: &str = "GENESIS";
40const LEDGER_VERSION: u8 = 2;
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(tag = "type", rename_all = "snake_case")]
44pub enum LedgerPayload {
45 Remember { trace: Box<Trace> },
46 Supersede { old_id: String, new_id: String },
47 Fulfill { id: String },
48 Purge { id: String, reason: String },
49}
50
51#[derive(Debug, Deserialize)]
53#[serde(deny_unknown_fields)]
54struct RawRecord<'a> {
55 seq: u64,
56 prev_hash: String,
57 #[serde(default)]
58 v: Option<u8>,
59 #[serde(borrow)]
60 payload: &'a RawValue,
61 hash: String,
62}
63
64#[derive(Serialize)]
65struct HashMaterialV1<'a> {
66 seq: u64,
67 prev_hash: &'a str,
68 payload: &'a LedgerPayload,
69}
70
71#[derive(Debug, Clone)]
73pub struct LedgerRecord {
74 pub seq: u64,
75 pub prev_hash: String,
76 pub hash: String,
77 pub version: u8,
78 payload_json: String,
79}
80
81impl LedgerRecord {
82 pub fn payload(&self) -> Result<LedgerPayload> {
83 Ok(serde_json::from_str(&self.payload_json)?)
84 }
85}
86
87#[derive(Debug, Clone)]
88pub struct TraceEntry {
89 pub trace: Trace,
90 pub status: TraceStatus,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
94pub struct LedgerHead {
95 pub seq: u64,
96 pub hash: String,
97}
98
99#[derive(Debug, Clone, Serialize)]
100pub struct VerifyReport {
101 pub ok: bool,
103 pub records: usize,
104 pub head: Option<LedgerHead>,
105 pub head_file_checked: bool,
107 pub errors: Vec<String>,
110 pub warnings: Vec<String>,
112 #[serde(skip_serializing_if = "Vec::is_empty")]
116 pub unacknowledged: Vec<RecordSummary>,
117}
118
119#[derive(Debug, Clone, Serialize, PartialEq)]
122pub struct RecordSummary {
123 pub seq: u64,
124 pub event: String,
126 pub trace_id: String,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 pub kind: Option<String>,
130 #[serde(skip_serializing_if = "Option::is_none")]
131 pub source: Option<String>,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub verified: Option<bool>,
134 #[serde(skip_serializing_if = "Option::is_none")]
136 pub claim: Option<String>,
137 #[serde(skip_serializing_if = "Option::is_none")]
139 pub detail: Option<String>,
140}
141
142impl RecordSummary {
143 fn of(record: &LedgerRecord) -> Self {
144 let mut summary = Self {
145 seq: record.seq,
146 event: "unreadable".into(),
147 trace_id: String::new(),
148 kind: None,
149 source: None,
150 verified: None,
151 claim: None,
152 detail: None,
153 };
154 let Ok(payload) = record.payload() else {
155 return summary;
156 };
157 match payload {
158 LedgerPayload::Remember { trace } => {
159 summary.event = "remember".into();
160 summary.trace_id = preview(&trace.id);
161 summary.kind = Some(trace.kind.to_string());
162 summary.source = Some(preview(&trace.provenance.source));
163 summary.verified = Some(trace.provenance.verified);
164 summary.claim = trace
165 .claim_key
166 .as_ref()
167 .zip(trace.claim_value.as_ref())
168 .map(|(key, value)| preview(&format!("{key}={value}")));
169 summary.detail = Some(preview(&trace.content));
170 }
171 LedgerPayload::Supersede { old_id, new_id } => {
172 summary.event = "supersede".into();
173 summary.trace_id = preview(&old_id);
174 summary.detail = Some(format!("superseded by {}", preview(&new_id)));
175 }
176 LedgerPayload::Fulfill { id } => {
177 summary.event = "fulfill".into();
178 summary.trace_id = preview(&id);
179 }
180 LedgerPayload::Purge { id, reason } => {
181 summary.event = "purge".into();
182 summary.trace_id = preview(&id);
183 summary.detail = Some(preview(&reason));
184 }
185 }
186 summary
187 }
188}
189
190impl std::fmt::Display for RecordSummary {
191 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192 write!(f, "seq {} {} {}", self.seq, self.event, self.trace_id)?;
193 let mut facts = Vec::new();
194 facts.extend(self.kind.clone());
195 facts.extend(self.source.as_ref().map(|s| format!("source {s}")));
196 if self.verified == Some(true) {
197 facts.push("verified".into());
198 }
199 facts.extend(self.claim.as_ref().map(|c| format!("claim {c}")));
200 if !facts.is_empty() {
201 write!(f, " ({})", facts.join(", "))?;
202 }
203 if let Some(detail) = &self.detail {
204 write!(f, ": {detail:?}")?;
205 }
206 Ok(())
207 }
208}
209
210#[derive(Debug, Clone, Serialize)]
212pub struct TailAcceptance {
213 pub accepted: Vec<RecordSummary>,
214 pub head: Option<LedgerHead>,
215}
216
217const PREVIEW_CHARS: usize = 80;
218const LISTED_IN_ERRORS: usize = 10;
220
221fn preview(text: &str) -> String {
223 if crate::secrets::contains_secret(text) {
224 return "[withheld: looks like a credential]".into();
225 }
226 let flat: String = text
227 .chars()
228 .map(|c| if c.is_control() { ' ' } else { c })
229 .collect();
230 match flat.char_indices().nth(PREVIEW_CHARS) {
231 Some((cut, _)) => format!("{}...", &flat[..cut]),
232 None => flat,
233 }
234}
235
236fn list_records(summaries: &[RecordSummary]) -> String {
237 let mut listed: Vec<String> = summaries
238 .iter()
239 .take(LISTED_IN_ERRORS)
240 .map(ToString::to_string)
241 .collect();
242 if summaries.len() > LISTED_IN_ERRORS {
243 listed.push(format!("and {} more", summaries.len() - LISTED_IN_ERRORS));
244 }
245 listed.join("; ")
246}
247
248#[derive(Debug, Clone, PartialEq)]
251enum HeadState {
252 Missing,
253 Present(LedgerHead),
254 Unreadable(String),
255}
256
257#[derive(Debug)]
258pub struct MemoryStore {
259 path: PathBuf,
260 policy: KernelPolicy,
261 records: Vec<LedgerRecord>,
262 traces: BTreeMap<String, TraceEntry>,
263 offset: u64,
265 torn_tail_bytes: u64,
266 missing_final_newline: bool,
267 replay_issues: Vec<String>,
268 head_at_load: HeadState,
269 nonblocking_writes: bool,
272}
273
274impl MemoryStore {
275 pub fn open(path: impl AsRef<Path>, policy: KernelPolicy) -> Result<Self> {
278 let path = path.as_ref().to_path_buf();
279 if let Some(parent) = path.parent() {
280 if !parent.as_os_str().is_empty() {
281 fs::create_dir_all(parent)?;
282 }
283 }
284 OpenOptions::new().create(true).append(true).open(&path)?;
286 Self::load(path, policy)
287 }
288
289 pub fn open_existing(path: impl AsRef<Path>, policy: KernelPolicy) -> Result<Self> {
291 let path = path.as_ref().to_path_buf();
292 if !path.is_file() {
293 return Err(KernelError::NotFound(format!(
294 "memory store {} (run `hikmah init` first)",
295 path.display()
296 )));
297 }
298 Self::load(path, policy)
299 }
300
301 fn load(path: PathBuf, policy: KernelPolicy) -> Result<Self> {
302 let mut store = Self {
303 path,
304 policy,
305 records: Vec::new(),
306 traces: BTreeMap::new(),
307 offset: 0,
308 torn_tail_bytes: 0,
309 missing_final_newline: false,
310 replay_issues: Vec::new(),
311 head_at_load: HeadState::Missing,
312 nonblocking_writes: false,
313 };
314 store.head_at_load = read_head(&store.head_path());
317 let bytes = fs::read(&store.path)?;
318 store.replay_bytes(&bytes)?;
319 Ok(store)
320 }
321
322 fn replay_bytes(&mut self, chunk: &[u8]) -> Result<()> {
325 let mut consumed = 0_usize;
326 let mut rest = chunk;
327 while let Some(pos) = rest.iter().position(|&b| b == b'\n') {
328 let line = std::str::from_utf8(&rest[..pos]).map_err(|_| KernelError::Integrity {
329 seq: self.records.len() as u64 + 1,
330 message: "ledger record is not valid UTF-8".into(),
331 })?;
332 self.replay_line(line)?;
333 consumed += pos + 1;
334 rest = &rest[pos + 1..];
335 }
336 self.offset += consumed as u64;
337 self.torn_tail_bytes = 0;
338 self.missing_final_newline = false;
339 if !rest.is_empty() {
340 match std::str::from_utf8(rest) {
343 Ok(text) if serde_json::from_str::<RawRecord>(text).is_ok() => {
344 self.replay_line(text)?;
345 self.offset += rest.len() as u64;
346 self.missing_final_newline = true;
347 }
348 Ok(text) if text.bytes().all(|b| matches!(b, b' ' | b'\t' | b'\r')) => {
350 self.offset += rest.len() as u64;
351 }
352 _ => self.torn_tail_bytes = rest.len() as u64,
353 }
354 }
355 Ok(())
356 }
357
358 fn replay_line(&mut self, line: &str) -> Result<()> {
359 if line.trim().is_empty() {
360 return Ok(());
361 }
362 let expected_seq = self.records.len() as u64 + 1;
363 let expected_prev = self
364 .records
365 .last()
366 .map(|r| r.hash.clone())
367 .unwrap_or_else(|| GENESIS.to_string());
368 let raw: RawRecord =
369 serde_json::from_str(line).map_err(|error| KernelError::Integrity {
370 seq: expected_seq,
371 message: format!("unparseable ledger record: {error}"),
372 })?;
373 if raw.seq != expected_seq {
374 return Err(KernelError::Integrity {
375 seq: raw.seq,
376 message: format!("expected sequence {expected_seq}"),
377 });
378 }
379 if raw.prev_hash != expected_prev {
380 return Err(KernelError::Integrity {
381 seq: raw.seq,
382 message: "previous hash does not match".into(),
383 });
384 }
385 let version = raw.v.unwrap_or(1);
386 let payload_json = raw.payload.get().to_string();
387 let payload: LedgerPayload =
388 serde_json::from_str(&payload_json).map_err(|error| KernelError::Integrity {
389 seq: raw.seq,
390 message: format!("unparseable payload: {error}"),
391 })?;
392 let calculated = match version {
393 1 => hash_v1(raw.seq, &raw.prev_hash, &payload)?,
394 2 => hash_v2(raw.seq, &raw.prev_hash, &payload_json),
395 other => {
396 return Err(KernelError::Integrity {
397 seq: raw.seq,
398 message: format!("unsupported ledger record version {other}"),
399 })
400 }
401 };
402 if calculated != raw.hash {
403 return Err(KernelError::Integrity {
404 seq: raw.seq,
405 message: "record hash does not match payload".into(),
406 });
407 }
408 if let Err(issue) = apply_payload(&mut self.traces, &payload) {
411 self.replay_issues
412 .push(format!("seq {}: {issue} (event skipped)", raw.seq));
413 }
414 self.records.push(LedgerRecord {
415 seq: raw.seq,
416 prev_hash: raw.prev_hash,
417 hash: raw.hash,
418 version,
419 payload_json,
420 });
421 Ok(())
422 }
423
424 pub fn remember(&mut self, trace: Trace) -> Result<(Trace, Vec<ClaimConflict>)> {
425 let (trace, conflicts, batch) = self.prepare_remember(trace, 0)?;
426 self.append_batch(batch)?;
427 Ok((trace, conflicts))
428 }
429
430 pub fn remember_many(
435 &mut self,
436 traces: Vec<Trace>,
437 ) -> Result<Vec<(Trace, Vec<ClaimConflict>)>> {
438 let mut batch = Vec::new();
439 let mut remembered = Vec::with_capacity(traces.len());
440 for (index, trace) in traces.into_iter().enumerate() {
441 let (trace, conflicts, payloads) = self.prepare_remember(trace, index)?;
442 batch.extend(payloads);
443 remembered.push((trace, conflicts));
444 }
445 self.append_batch(batch)?;
447 Ok(remembered)
448 }
449
450 fn prepare_remember(
453 &self,
454 mut trace: Trace,
455 batch_index: usize,
456 ) -> Result<(Trace, Vec<ClaimConflict>, Vec<LedgerPayload>)> {
457 trace.validate()?;
458 if trace.privacy == PrivacyClass::Sensitive && !self.policy.allow_sensitive_persistence {
459 return Err(KernelError::Invalid(
460 "sensitive persistence is disabled; use an encrypted vault adapter or lower the privacy class explicitly"
461 .into(),
462 ));
463 }
464 if trace.id.is_empty() {
465 trace.id = self.next_trace_id(&trace, batch_index);
466 }
467 trace.validate()?;
468 if self.traces.contains_key(&trace.id) {
469 return Err(KernelError::Invalid(format!(
470 "trace id already exists: {}",
471 trace.id
472 )));
473 }
474 if let Some(old_id) = &trace.supersedes {
475 let target = self
476 .traces
477 .get(old_id)
478 .ok_or_else(|| KernelError::NotFound(old_id.clone()))?;
479 if target.status != TraceStatus::Active {
480 return Err(KernelError::Invalid(format!(
481 "cannot supersede {old_id}: it is {:?}, not active",
482 target.status
483 )));
484 }
485 if trace.is_model_authored() {
486 return Err(KernelError::Invalid(
487 "model-authored traces cannot supersede other traces".into(),
488 ));
489 }
490 if target.trace.provenance.verified && !trace.provenance.verified {
491 return Err(KernelError::Invalid(format!(
492 "cannot supersede verified trace {old_id} with an unverified one; record a conflicting claim instead"
493 )));
494 }
495 }
496 if let Some(outcome) = &trace.outcome {
497 let prediction = self
498 .traces
499 .get(&outcome.prediction_id)
500 .ok_or_else(|| KernelError::NotFound(outcome.prediction_id.clone()))?;
501 if prediction.trace.kind != TraceKind::Prediction {
502 return Err(KernelError::Invalid(format!(
503 "{} is not a prediction trace",
504 outcome.prediction_id
505 )));
506 }
507 if prediction.status != TraceStatus::Active {
508 return Err(KernelError::Invalid(format!(
509 "cannot record an outcome for {}: the prediction is {:?}, not active, and no longer counts toward calibration",
510 outcome.prediction_id, prediction.status
511 )));
512 }
513 if let Some(record) = &prediction.trace.prediction {
514 let observed = outcome.observed.trim();
515 if !record.answer_space.is_empty()
516 && !record.answer_space.iter().any(|v| v == observed)
517 {
518 return Err(KernelError::Invalid(format!(
519 "observed value `{observed}` is not one of {:?}",
520 record.answer_space
521 )));
522 }
523 }
524 }
525
526 let superseded = trace.supersedes.clone();
527 let conflicts = detect_conflicts(
528 &trace,
529 self.active_traces()
530 .filter(|existing| Some(&existing.id) != superseded.as_ref()),
531 );
532 let mut batch = vec![LedgerPayload::Remember {
533 trace: Box::new(trace.clone()),
534 }];
535 if let Some(old_id) = superseded {
536 batch.push(LedgerPayload::Supersede {
537 old_id,
538 new_id: trace.id.clone(),
539 });
540 }
541 Ok((trace, conflicts, batch))
542 }
543
544 pub fn fulfill(&mut self, id: impl Into<String>) -> Result<()> {
545 self.append_batch(vec![LedgerPayload::Fulfill { id: id.into() }])
546 }
547
548 pub fn purge(&mut self, id: impl Into<String>, reason: impl Into<String>) -> Result<()> {
551 self.append_batch(vec![LedgerPayload::Purge {
552 id: id.into(),
553 reason: reason.into(),
554 }])
555 }
556
557 pub fn get(&self, id: &str) -> Option<&TraceEntry> {
558 self.traces.get(id)
559 }
560
561 pub fn all(&self) -> impl Iterator<Item = &TraceEntry> {
562 self.traces.values()
563 }
564
565 pub fn active_traces(&self) -> impl Iterator<Item = &Trace> {
566 self.traces
567 .values()
568 .filter(|entry| entry.status == TraceStatus::Active)
569 .map(|entry| &entry.trace)
570 }
571
572 pub fn policy(&self) -> &KernelPolicy {
573 &self.policy
574 }
575
576 pub fn record_count(&self) -> usize {
577 self.records.len()
578 }
579
580 pub fn records(&self) -> &[LedgerRecord] {
581 &self.records
582 }
583
584 pub fn head(&self) -> Option<LedgerHead> {
585 self.records.last().map(|r| LedgerHead {
586 seq: r.seq,
587 hash: r.hash.clone(),
588 })
589 }
590
591 pub fn head_path(&self) -> PathBuf {
592 let mut name = self.path.as_os_str().to_owned();
593 name.push(".head");
594 PathBuf::from(name)
595 }
596
597 pub fn verify(&self) -> Result<()> {
599 let report = self.verify_report(None)?;
600 if report.ok {
601 Ok(())
602 } else {
603 Err(KernelError::Integrity {
604 seq: report.records as u64,
605 message: report.errors.join("; "),
606 })
607 }
608 }
609
610 pub fn verify_report(&self, expected_head: Option<&str>) -> Result<VerifyReport> {
615 let mut prev = GENESIS.to_string();
616 for record in &self.records {
617 if record.prev_hash != prev {
618 return Err(KernelError::Integrity {
619 seq: record.seq,
620 message: "previous hash mismatch".into(),
621 });
622 }
623 let calculated = match record.version {
624 1 => hash_v1(record.seq, &record.prev_hash, &record.payload()?)?,
625 _ => hash_v2(record.seq, &record.prev_hash, &record.payload_json),
626 };
627 if calculated != record.hash {
628 return Err(KernelError::Integrity {
629 seq: record.seq,
630 message: "hash mismatch".into(),
631 });
632 }
633 prev = record.hash.clone();
634 }
635
636 let mut warnings = self.replay_issues.clone();
637 if self.torn_tail_bytes > 0 {
638 warnings.push(format!(
639 "torn final line of {} bytes ignored (the next write repairs it)",
640 self.torn_tail_bytes
641 ));
642 }
643 let head = self.head();
644 let mut errors = Vec::new();
645 let mut unacknowledged = Vec::new();
646 let mut head_file_checked = false;
647 match &self.head_at_load {
648 HeadState::Missing => {
649 if !self.records.is_empty() {
650 warnings.push(
651 "no head file: truncation and appended records cannot be detected until a write (or `hikmah verify-ledger --reset-head`) creates one".into(),
652 );
653 }
654 }
655 HeadState::Unreadable(error) => {
656 errors.push(format!(
657 "head file is unreadable ({error}); a person must inspect it, then run `hikmah verify-ledger --reset-head` from their own terminal to accept the current ledger"
658 ));
659 }
660 HeadState::Present(stored) => {
661 head_file_checked = true;
662 let count = self.records.len() as u64;
663 if stored.seq > count {
664 errors.push(format!(
665 "ledger has {count} records but its head file records seq {}: records were removed",
666 stored.seq
667 ));
668 } else if stored.seq > 0
669 && self.records[(stored.seq - 1) as usize].hash != stored.hash
670 {
671 errors.push(format!(
672 "record {} does not match the head file hash: the ledger was rewritten",
673 stored.seq
674 ));
675 } else if stored.seq < count {
676 let acknowledged = self.acknowledged_now(stored.seq);
677 if acknowledged < count {
678 unacknowledged = self.summaries_after(acknowledged);
679 errors.push(format!(
680 "{} record(s) after the recorded head (seq {acknowledged}) were never acknowledged by a hikmah write: appended by another program, written by an older binary, or left by a crash before the head update. Writes are refused until a person inspects them and runs `hikmah verify-ledger --accept-tail` from their own terminal",
681 count - acknowledged
682 ));
683 }
684 }
685 }
686 }
687 if let Some(expected) = expected_head {
688 let actual = head.as_ref().map(|h| h.hash.as_str()).unwrap_or(GENESIS);
689 if actual != expected {
690 errors.push(format!(
691 "head hash {actual} does not match the pinned head {expected}"
692 ));
693 }
694 }
695 Ok(VerifyReport {
696 ok: errors.is_empty(),
697 records: self.records.len(),
698 head,
699 head_file_checked,
700 errors,
701 warnings,
702 unacknowledged,
703 })
704 }
705
706 fn summaries_after(&self, seq: u64) -> Vec<RecordSummary> {
708 self.records
709 .iter()
710 .skip(seq as usize)
711 .map(RecordSummary::of)
712 .collect()
713 }
714
715 fn acknowledged_now(&self, loaded_seq: u64) -> u64 {
725 let n = self.records.len() as u64;
726 let Some(last) = self.records.last() else {
727 return n;
728 };
729 let lock = OpenOptions::new()
730 .write(true)
731 .create(true)
732 .truncate(false)
733 .open(self.lock_path())
734 .or_else(|_| File::open(self.lock_path()));
735 let Ok(lock) = lock else {
738 return loaded_seq;
739 };
740 if lock.lock_shared().is_err() {
741 return loaded_seq;
742 }
743 match read_head(&self.head_path()) {
744 HeadState::Present(now) if now.seq == n && now.hash == last.hash => n,
745 HeadState::Present(now) if now.seq > n => {
746 let Ok(fresh) = Self::load(self.path.clone(), self.policy.clone()) else {
748 return loaded_seq;
749 };
750 let covered = match &fresh.head_at_load {
751 HeadState::Present(head) => {
752 head.seq >= n
753 && head.seq <= fresh.records.len() as u64
754 && fresh.records[(head.seq - 1) as usize].hash == head.hash
755 && fresh.records[(n - 1) as usize].hash == last.hash
756 }
757 _ => false,
758 };
759 if covered {
760 n
761 } else {
762 loaded_seq
763 }
764 }
765 HeadState::Present(now)
766 if now.seq > loaded_seq
767 && now.seq < n
768 && self.records[(now.seq - 1) as usize].hash == now.hash =>
769 {
770 now.seq
771 }
772 _ => loaded_seq,
773 }
774 }
775
776 fn append_batch(&mut self, payloads: Vec<LedgerPayload>) -> Result<()> {
777 if payloads.is_empty() {
778 return Ok(());
779 }
780 let (_lock, mut file) = self.open_locked()?;
781 self.sync_tail(&mut file)?;
782 self.check_head_before_write()?;
783 validate_batch(&self.traces, &payloads)?;
784
785 let mut buffer = String::new();
786 let mut staged = Vec::with_capacity(payloads.len());
787 let mut prev = self
788 .records
789 .last()
790 .map(|r| r.hash.clone())
791 .unwrap_or_else(|| GENESIS.to_string());
792 let mut seq = self.records.len() as u64;
793 for payload in &payloads {
794 seq += 1;
795 let payload_json = serde_json::to_string(payload)?;
796 let hash = hash_v2(seq, &prev, &payload_json);
797 buffer.push_str(&format!(
798 "{{\"seq\":{seq},\"prev_hash\":{},\"v\":{LEDGER_VERSION},\"payload\":{payload_json},\"hash\":\"{hash}\"}}\n",
799 serde_json::to_string(&prev)?
800 ));
801 staged.push(LedgerRecord {
802 seq,
803 prev_hash: prev.clone(),
804 hash: hash.clone(),
805 version: LEDGER_VERSION,
806 payload_json,
807 });
808 prev = hash;
809 }
810 file.seek(SeekFrom::End(0))?;
811 file.write_all(buffer.as_bytes())?;
812 file.sync_data()?;
813 for payload in &payloads {
814 apply_payload(&mut self.traces, payload).map_err(KernelError::Invalid)?;
815 }
816 self.records.extend(staged);
817 self.offset += buffer.len() as u64;
818 self.write_head()?;
819 Ok(())
820 }
821
822 fn sync_tail(&mut self, file: &mut File) -> Result<()> {
824 let len = file.metadata()?.len();
825 if len < self.offset {
826 return Err(KernelError::Integrity {
827 seq: self.records.len() as u64,
828 message: "ledger is shorter than when it was opened".into(),
829 });
830 }
831 if len > self.offset {
832 file.seek(SeekFrom::Start(self.offset))?;
833 let mut tail = Vec::new();
834 file.read_to_end(&mut tail)?;
835 self.replay_bytes(&tail)?;
836 }
837 if self.torn_tail_bytes > 0 {
838 if self.records.is_empty() {
841 let mut start = [0_u8; 7];
842 file.seek(SeekFrom::Start(self.offset))?;
843 let read = file.read(&mut start)?;
844 let signature: &[u8] = b"{\"seq\"";
845 let begins_like_record =
846 start[..read].starts_with(signature) || signature.starts_with(&start[..read]);
847 if !begins_like_record {
848 return Err(KernelError::Integrity {
849 seq: 0,
850 message: format!(
851 "{} does not look like a Hikmah ledger (no valid record, and its content does not start like one); refusing to write to it",
852 self.path.display()
853 ),
854 });
855 }
856 }
857 file.set_len(self.offset)?;
858 self.replay_issues.push(format!(
859 "repaired a torn final line of {} bytes",
860 self.torn_tail_bytes
861 ));
862 self.torn_tail_bytes = 0;
863 }
864 if self.missing_final_newline {
865 file.seek(SeekFrom::End(0))?;
866 file.write_all(b"\n")?;
867 self.offset += 1;
868 self.missing_final_newline = false;
869 }
870 Ok(())
871 }
872
873 fn check_head_before_write(&self) -> Result<()> {
879 let count = self.records.len() as u64;
880 match read_head(&self.head_path()) {
881 HeadState::Missing => Ok(()),
882 HeadState::Unreadable(error) => Err(KernelError::Integrity {
883 seq: count,
884 message: format!(
885 "head file is unreadable ({error}); a person must inspect the ledger, then run `hikmah verify-ledger --reset-head` from their own terminal"
886 ),
887 }),
888 HeadState::Present(head) => {
889 let rewritten = head.seq > count
890 || (head.seq > 0 && self.records[(head.seq - 1) as usize].hash != head.hash);
891 if rewritten {
892 Err(KernelError::Integrity {
893 seq: head.seq,
894 message: "the ledger no longer matches its head file (records removed or rewritten); refusing to write. A person must inspect it, then run `hikmah verify-ledger --reset-head` from their own terminal to accept the current ledger".into(),
895 })
896 } else if head.seq < count {
897 Err(KernelError::Integrity {
898 seq: head.seq + 1,
899 message: format!(
900 "{} record(s) after the recorded head (seq {}) were never acknowledged by a hikmah write (appended by another program, written by an older binary, or left by a crash before the head update); refusing to write so they are not silently accepted. Unacknowledged: {}. A person must inspect them, then run `hikmah verify-ledger --accept-tail` from their own terminal to accept them (it is refused inside an AI agent session)",
901 count - head.seq,
902 head.seq,
903 list_records(&self.summaries_after(head.seq))
904 ),
905 })
906 } else {
907 Ok(())
908 }
909 }
910 }
911 }
912
913 pub fn accept_tail(&mut self) -> Result<TailAcceptance> {
918 let (_lock, mut file) = self.open_locked()?;
919 self.sync_tail(&mut file)?;
920 let count = self.records.len() as u64;
921 let head = match read_head(&self.head_path()) {
922 HeadState::Present(head) => head,
923 HeadState::Missing => {
924 return Err(KernelError::Invalid(
925 "there is no head file, so no records are marked unacknowledged; the next write creates one (or run `hikmah verify-ledger --reset-head`)".into(),
926 ))
927 }
928 HeadState::Unreadable(error) => {
929 return Err(KernelError::Integrity {
930 seq: count,
931 message: format!(
932 "head file is unreadable ({error}); a person must inspect the ledger, then run `hikmah verify-ledger --reset-head` from their own terminal"
933 ),
934 })
935 }
936 };
937 if head.seq > count
938 || (head.seq > 0 && self.records[(head.seq - 1) as usize].hash != head.hash)
939 {
940 return Err(KernelError::Integrity {
941 seq: head.seq,
942 message: "records before the head were removed or rewritten; accepting the tail cannot repair that. A person must inspect the ledger, then run `hikmah verify-ledger --reset-head` from their own terminal".into(),
943 });
944 }
945 let accepted = self.summaries_after(head.seq);
946 if !accepted.is_empty() {
947 self.write_head()?;
948 }
949 Ok(TailAcceptance {
950 accepted,
951 head: self.head(),
952 })
953 }
954
955 pub fn reset_head(&mut self) -> Result<Option<LedgerHead>> {
957 let (_lock, mut file) = self.open_locked()?;
958 self.sync_tail(&mut file)?;
959 let path = self.head_path();
960 if self.records.is_empty() {
961 if path.exists() {
962 fs::remove_file(&path)?;
963 }
964 self.head_at_load = HeadState::Missing;
965 return Ok(None);
966 }
967 self.write_head()?;
968 Ok(self.head())
969 }
970
971 pub fn set_nonblocking_writes(&mut self, nonblocking: bool) {
974 self.nonblocking_writes = nonblocking;
975 }
976
977 pub fn lock_path(&self) -> PathBuf {
978 let mut name = self.path.as_os_str().to_owned();
979 name.push(".lock");
980 PathBuf::from(name)
981 }
982
983 fn open_locked(&self) -> Result<(File, File)> {
987 let lock = OpenOptions::new()
988 .write(true)
989 .create(true)
990 .truncate(false)
991 .open(self.lock_path())?;
992 if self.nonblocking_writes {
993 match lock.try_lock() {
994 Ok(()) => {}
995 Err(std::fs::TryLockError::WouldBlock) => {
996 return Err(KernelError::Invalid(format!(
997 "{} is locked by another writer",
998 self.path.display()
999 )))
1000 }
1001 Err(std::fs::TryLockError::Error(error)) => return Err(error.into()),
1002 }
1003 } else {
1004 lock.lock()?;
1005 }
1006 let file = OpenOptions::new()
1007 .read(true)
1008 .write(true)
1009 .create(true)
1010 .truncate(false)
1011 .open(&self.path)?;
1012 Ok((lock, file))
1013 }
1014
1015 fn write_head(&mut self) -> Result<()> {
1016 let Some(head) = self.head() else {
1017 return Ok(());
1018 };
1019 let path = self.head_path();
1020 let mut tmp = path.as_os_str().to_owned();
1021 tmp.push(".tmp");
1022 let tmp = PathBuf::from(tmp);
1023 {
1024 let mut file = File::create(&tmp)?;
1025 file.write_all(&serde_json::to_vec(&head)?)?;
1026 file.sync_all()?;
1027 }
1028 fs::rename(&tmp, &path)?;
1029 self.head_at_load = HeadState::Present(head);
1030 Ok(())
1031 }
1032
1033 fn next_trace_id(&self, trace: &Trace, batch_index: usize) -> String {
1034 let seed = format!(
1035 "{}:{}:{}:{}",
1036 self.records.len() + 1 + batch_index,
1037 trace.created_at_ms,
1038 trace.kind,
1039 trace.content
1040 );
1041 let hash = blake3::hash(seed.as_bytes()).to_hex().to_string();
1042 format!("tr_{}", &hash[..16])
1043 }
1044}
1045
1046fn read_head(path: &Path) -> HeadState {
1047 match fs::read(path) {
1048 Err(error) if error.kind() == std::io::ErrorKind::NotFound => HeadState::Missing,
1049 Err(error) => HeadState::Unreadable(error.to_string()),
1050 Ok(bytes) => match serde_json::from_slice::<LedgerHead>(&bytes) {
1051 Ok(head) => HeadState::Present(head),
1052 Err(error) => HeadState::Unreadable(error.to_string()),
1053 },
1054 }
1055}
1056
1057fn hash_v1(seq: u64, prev_hash: &str, payload: &LedgerPayload) -> Result<String> {
1058 let material = HashMaterialV1 {
1059 seq,
1060 prev_hash,
1061 payload,
1062 };
1063 let bytes = serde_json::to_vec(&material)?;
1064 let mut hasher = Hasher::new();
1065 hasher.update(&bytes);
1066 Ok(hasher.finalize().to_hex().to_string())
1067}
1068
1069fn hash_v2(seq: u64, prev_hash: &str, payload_json: &str) -> String {
1070 let mut hasher = Hasher::new();
1071 hasher.update(b"hikmah-ledger-v2\n");
1072 hasher.update(seq.to_string().as_bytes());
1073 hasher.update(b"\n");
1074 hasher.update(prev_hash.as_bytes());
1075 hasher.update(b"\n");
1076 hasher.update(payload_json.as_bytes());
1077 hasher.finalize().to_hex().to_string()
1078}
1079
1080fn validate_batch(traces: &BTreeMap<String, TraceEntry>, payloads: &[LedgerPayload]) -> Result<()> {
1082 let mut added: BTreeMap<String, (TraceKind, TraceStatus)> = BTreeMap::new();
1083 let mut changed: BTreeSet<String> = BTreeSet::new();
1084 let lookup = |id: &str,
1085 added: &BTreeMap<String, (TraceKind, TraceStatus)>|
1086 -> Option<(TraceKind, TraceStatus)> {
1087 added
1088 .get(id)
1089 .copied()
1090 .or_else(|| traces.get(id).map(|e| (e.trace.kind, e.status)))
1091 };
1092 for payload in payloads {
1093 match payload {
1094 LedgerPayload::Remember { trace } => {
1095 trace.validate()?;
1096 if lookup(&trace.id, &added).is_some() {
1097 return Err(KernelError::Invalid(format!(
1098 "trace id already exists: {}",
1099 trace.id
1100 )));
1101 }
1102 if let Some(outcome) = &trace.outcome {
1105 let id = &outcome.prediction_id;
1106 let (kind, status) =
1107 lookup(id, &added).ok_or_else(|| KernelError::NotFound(id.clone()))?;
1108 if kind != TraceKind::Prediction {
1109 return Err(KernelError::Invalid(format!(
1110 "{id} is not a prediction trace"
1111 )));
1112 }
1113 if status != TraceStatus::Active || changed.contains(id) {
1114 return Err(KernelError::Invalid(format!(
1115 "cannot record an outcome for {id}: the prediction is not active"
1116 )));
1117 }
1118 }
1119 added.insert(trace.id.clone(), (trace.kind, TraceStatus::Active));
1120 }
1121 LedgerPayload::Supersede { old_id, new_id } => {
1122 let (_, status) =
1123 lookup(old_id, &added).ok_or_else(|| KernelError::NotFound(old_id.clone()))?;
1124 if status != TraceStatus::Active || changed.contains(old_id) {
1125 return Err(KernelError::Invalid(format!(
1126 "cannot supersede {old_id}: it is not active"
1127 )));
1128 }
1129 if lookup(new_id, &added).is_none() {
1130 return Err(KernelError::NotFound(new_id.clone()));
1131 }
1132 changed.insert(old_id.clone());
1133 }
1134 LedgerPayload::Fulfill { id } => {
1135 let (kind, status) =
1136 lookup(id, &added).ok_or_else(|| KernelError::NotFound(id.clone()))?;
1137 if kind != TraceKind::Commitment {
1138 return Err(KernelError::Invalid(format!(
1139 "only commitments can be fulfilled; {id} is a {kind}"
1140 )));
1141 }
1142 if status != TraceStatus::Active || changed.contains(id) {
1143 return Err(KernelError::Invalid(format!(
1144 "cannot fulfill {id}: it is not active"
1145 )));
1146 }
1147 changed.insert(id.clone());
1148 }
1149 LedgerPayload::Purge { id, reason } => {
1150 let (_, status) =
1151 lookup(id, &added).ok_or_else(|| KernelError::NotFound(id.clone()))?;
1152 if status == TraceStatus::Purged {
1153 return Err(KernelError::Invalid(format!("{id} is already purged")));
1154 }
1155 if reason.trim().is_empty() {
1156 return Err(KernelError::Invalid("purge needs a reason".into()));
1157 }
1158 if crate::secrets::contains_secret(reason) {
1160 return Err(KernelError::Invalid(
1161 "purge reason appears to contain a credential; describe the leak without quoting it".into(),
1162 ));
1163 }
1164 changed.insert(id.clone());
1165 }
1166 }
1167 }
1168 Ok(())
1169}
1170
1171fn apply_payload(
1172 traces: &mut BTreeMap<String, TraceEntry>,
1173 payload: &LedgerPayload,
1174) -> std::result::Result<(), String> {
1175 let mut set_status = |id: &str, status: TraceStatus| -> std::result::Result<(), String> {
1176 let entry = traces
1177 .get_mut(id)
1178 .ok_or_else(|| format!("trace not found: {id}"))?;
1179 entry.status = status;
1180 Ok(())
1181 };
1182 match payload {
1183 LedgerPayload::Remember { trace } => {
1184 traces.insert(
1185 trace.id.clone(),
1186 TraceEntry {
1187 trace: (**trace).clone(),
1188 status: TraceStatus::Active,
1189 },
1190 );
1191 Ok(())
1192 }
1193 LedgerPayload::Supersede { old_id, .. } => set_status(old_id, TraceStatus::Superseded),
1194 LedgerPayload::Fulfill { id } => set_status(id, TraceStatus::Fulfilled),
1195 LedgerPayload::Purge { id, .. } => set_status(id, TraceStatus::Purged),
1196 }
1197}
1198
1199#[cfg(test)]
1200mod tests {
1201 use super::*;
1202 use crate::trace::OutcomeRecord;
1203
1204 fn state(prediction_status: TraceStatus) -> BTreeMap<String, TraceEntry> {
1205 let mut prediction = Trace::new(TraceKind::Prediction, "Will it break?", "model:fixture");
1206 prediction.id = "tr_prediction".into();
1207 let mut note = Trace::new(TraceKind::Observation, "CI is green", "ci");
1208 note.id = "tr_note".into();
1209 [(prediction, prediction_status), (note, TraceStatus::Active)]
1210 .into_iter()
1211 .map(|(trace, status)| (trace.id.clone(), TraceEntry { trace, status }))
1212 .collect()
1213 }
1214
1215 fn outcome_for(prediction_id: &str) -> LedgerPayload {
1216 let mut trace = Trace::new(TraceKind::Outcome, "observed in CI", "ci");
1217 trace.id = "tr_outcome".into();
1218 trace.outcome = Some(OutcomeRecord {
1219 prediction_id: prediction_id.into(),
1220 observed: "false".into(),
1221 });
1222 LedgerPayload::Remember {
1223 trace: Box::new(trace),
1224 }
1225 }
1226
1227 #[test]
1230 fn an_outcome_batch_is_checked_against_the_prediction_it_resolves() {
1231 let active = state(TraceStatus::Active);
1232 assert!(validate_batch(&active, &[outcome_for("tr_prediction")]).is_ok());
1233 assert!(matches!(
1234 validate_batch(&active, &[outcome_for("tr_missing")]),
1235 Err(KernelError::NotFound(id)) if id == "tr_missing"
1236 ));
1237 assert!(matches!(
1238 validate_batch(&active, &[outcome_for("tr_note")]),
1239 Err(KernelError::Invalid(message)) if message.contains("not a prediction")
1240 ));
1241 for status in [TraceStatus::Purged, TraceStatus::Superseded] {
1242 assert!(matches!(
1243 validate_batch(&state(status), &[outcome_for("tr_prediction")]),
1244 Err(KernelError::Invalid(message)) if message.contains("not active")
1245 ));
1246 }
1247 let purge = LedgerPayload::Purge {
1249 id: "tr_prediction".into(),
1250 reason: "recorded by mistake".into(),
1251 };
1252 assert!(matches!(
1253 validate_batch(&active, &[purge, outcome_for("tr_prediction")]),
1254 Err(KernelError::Invalid(message)) if message.contains("not active")
1255 ));
1256 }
1257}