Skip to main content

hikmah_kernel/
ledger.rs

1//! Append-only, hash-chained TraceWeave ledger.
2//!
3//! Format history:
4//! - v1 (≤ 3.0.0): `hash = blake3(json{seq, prev_hash, payload})` where the payload is the
5//!   *re-serialized* Rust struct. Still verified for backward compatibility.
6//! - v2 (3.1.0+): `hash = blake3("hikmah-ledger-v2\n" seq "\n" prev_hash "\n" payload_bytes)`
7//!   over the exact payload bytes stored on disk, so schema growth never breaks old hashes and
8//!   any injected byte inside the payload is detected.
9//!
10//! Writes validate every event against current state *before* anything touches disk, hold an
11//! exclusive lock on a `<store>.lock` sidecar (never on the ledger itself: Windows locks are
12//! mandatory and would block lock-free readers), re-read records other processes appended since this store was opened,
13//! and write each batch with a single `write_all`. A torn final line (crash mid-write) is ignored
14//! on read and truncated by the next writer. A `<store>.head` file records the latest
15//! `{seq, hash}`: it is read before the ledger (so a concurrent writer cannot cause a false
16//! alarm), checked under the lock before every write (so a write cannot paper over a truncation, a
17//! rewrite, or records appended without a head update), and can be pinned externally through
18//! `verify_report(Some(head))`. `accept_tail` acknowledges appended records after inspection;
19//! `reset_head` accepts the current ledger after a deliberate repair. Both are a person's
20//! decision, and the CLI refuses them inside a detected AI agent session (see [`crate::principal`]).
21//!
22//! The chain is unkeyed: anyone who can write the ledger can compute valid hashes. The chain alone
23//! detects a record edited, reordered, or injected inside it. The head file adds truncation,
24//! rewrites, and records appended without a head update. Neither detects an append or a re-chain
25//! by someone who can also update or delete the head file; only a head pinned outside the writer's
26//! reach (`verify_report(Some(head))`) does.
27use 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/// One persisted record as it sits on disk (payload kept as exact bytes).
52#[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/// In-memory view of a verified record.
72#[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    /// Chain, sequence numbers, hashes, and head checks all passed (`errors` is empty).
102    pub ok: bool,
103    pub records: usize,
104    pub head: Option<LedgerHead>,
105    /// `true` when a `<store>.head` file exists and matched the chain.
106    pub head_file_checked: bool,
107    /// Integrity findings; any entry makes `ok` false. Records removed or rewritten, an unreadable
108    /// head file, records past the head that no write acknowledged, a pinned head mismatch.
109    pub errors: Vec<String>,
110    /// Non-integrity problems: events that could not be applied, a torn tail, no head file.
111    pub warnings: Vec<String>,
112    /// Records past the head file that no hikmah write acknowledged (appended by another program,
113    /// an older binary, or left by a crash before the head update). Writes are refused until they
114    /// are inspected and accepted with [`MemoryStore::accept_tail`].
115    #[serde(skip_serializing_if = "Vec::is_empty")]
116    pub unacknowledged: Vec<RecordSummary>,
117}
118
119/// One ledger record, summarized for a person to inspect before accepting it. Free text is cut to
120/// a short preview, and text that looks like a credential is withheld.
121#[derive(Debug, Clone, Serialize, PartialEq)]
122pub struct RecordSummary {
123    pub seq: u64,
124    /// `remember`, `supersede`, `fulfill`, or `purge`.
125    pub event: String,
126    /// The trace the event writes or changes.
127    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    /// `claim_key=claim_value`.
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub claim: Option<String>,
137    /// Content preview, purge reason, or the superseding trace.
138    #[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/// What [`MemoryStore::accept_tail`] acknowledged.
211#[derive(Debug, Clone, Serialize)]
212pub struct TailAcceptance {
213    pub accepted: Vec<RecordSummary>,
214    pub head: Option<LedgerHead>,
215}
216
217const PREVIEW_CHARS: usize = 80;
218/// At most this many unacknowledged records are listed in a refusal message.
219const LISTED_IN_ERRORS: usize = 10;
220
221/// A single-line, bounded preview of free text that never echoes something shaped like a credential.
222fn 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/// The `<store>.head` file as read *before* the ledger (so a concurrent writer can never make
249/// the snapshot look ahead of the records it is compared with).
250#[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    /// Bytes of complete, replayed lines.
264    offset: u64,
265    torn_tail_bytes: u64,
266    missing_final_newline: bool,
267    replay_issues: Vec<String>,
268    head_at_load: HeadState,
269    /// When set, a write fails at once if another process holds the writer lock instead of
270    /// waiting for it (see [`Self::set_nonblocking_writes`]).
271    nonblocking_writes: bool,
272}
273
274impl MemoryStore {
275    /// Open a store, creating the file (and parent directories) when it does not exist.
276    /// Use this for commands that write. Read-only commands should use [`Self::open_existing`].
277    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        // Never truncate: `File::create` would race with a concurrent creator and erase its record.
285        OpenOptions::new().create(true).append(true).open(&path)?;
286        Self::load(path, policy)
287    }
288
289    /// Open a store that must already exist. A mistyped path is an error, not an empty store.
290    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        // Read the head before the ledger: heads are written only after their records are
315        // synced, so this snapshot can never be ahead of what we are about to read.
316        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    /// Replay complete lines of `chunk` (which starts at `self.offset`). Works on bytes so a
323    /// torn final line that ends inside a multi-byte character is still treated as a torn tail.
324    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            // A final line without '\n'. Either a complete record whose newline never landed
341            // (older binaries wrote record and newline in two syscalls) or a torn write.
342            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                // Only JSON whitespace may be kept in front of the next record.
349                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        // A hash-valid record that cannot be applied (for example a supersede of an unknown id
409        // written by an older binary) is a logic problem, not tampering: keep the store usable.
410        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    /// Append several traces as one batch: one lock, one validation pass, one write, so either
431    /// every trace is stored or none is. Each trace gets the same checks as [`Self::remember`]
432    /// against the stored state; an outcome must resolve a prediction that is already stored.
433    /// Conflicts are reported against stored traces, not between traces of the same batch.
434    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        // `validate_batch` (inside `append_batch`) also rejects duplicate ids within the batch.
446        self.append_batch(batch)?;
447        Ok(remembered)
448    }
449
450    /// Validate one trace against the stored state and build its ledger events. `batch_index`
451    /// keeps generated ids distinct between traces of one batch.
452    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    /// Tombstone a trace: it leaves recall, consolidation, and commitments. The content stays in
549    /// the append-only ledger; this is not erasure.
550    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    /// Integrity check. Errors on any chain break; returns `Ok(())` only when the report is ok.
598    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    /// Full verification report. `expected_head` pins the latest hash (for example a value kept
611    /// in CI or in git) so a wholesale re-chain by someone with write access is detected. When the
612    /// loaded ledger holds records past its head snapshot, this waits for in-flight writes (a
613    /// shared lock on the `<store>.lock` sidecar) before reporting them as unacknowledged.
614    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    /// Summaries of the records after `seq` (for inspection before [`Self::accept_tail`]).
707    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    /// A lock-free reader can load the ledger while a write is in flight: the writer syncs its
716    /// records before it updates the head, so this snapshot may hold records its head snapshot
717    /// (`loaded_seq`) does not cover yet. Only in that case, wait for in-flight writes to finish (a
718    /// shared lock on the `<store>.lock` sidecar, which every writer holds exclusively until its
719    /// head update has landed), then re-read the head and, if it has moved past this snapshot, the
720    /// ledger. Returns how many leading records of this snapshot the head acknowledges now: all of
721    /// them when every write completed, the new head's seq when it moved only part of the way (so
722    /// a legitimate record is never listed beside a forged one), and `loaded_seq` when the head
723    /// cannot be confirmed.
724    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        // Without the lock an in-flight write cannot be told apart from an unacknowledged tail;
736        // report the tail rather than guess.
737        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                // Confirm this snapshot is a prefix of the ledger that the newer head covers.
747                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    /// Under the write lock: pick up records other processes appended, repair a torn tail.
823    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            // Truncating a torn record is a repair; truncating a file that was never a ledger is
839            // data loss. With no valid record, only cut bytes that begin like a ledger record.
840            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    /// Under the write lock: refuse to append when the head file shows the ledger was truncated or
874    /// rewritten, or holds records no hikmah write acknowledged, so an ordinary write can never
875    /// paper over tamper evidence or silently approve a forged append. Every completed write
876    /// updates the head before it releases the lock, so a head behind the ledger here is never
877    /// another writer in flight.
878    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    /// Acknowledge records appended after the head file (after inspecting them): the head moves
914    /// to the end of the ledger and writes are allowed again. Unlike [`Self::reset_head`] this
915    /// accepts only appended records. It refuses when records were removed or rewritten, when the
916    /// head file is unreadable, and when there is no head file to extend.
917    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    /// Explicitly accept the current ledger as the new head (after a deliberate repair).
956    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    /// Make writes fail immediately when another process holds the writer lock, instead of
972    /// waiting. For callers such as the Stop hook that must never stall on a busy store.
973    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    /// Take the exclusive writer lock, then open the ledger read+write. The ledger is not opened
984    /// in append mode: on Windows an append-only handle cannot `set_len` to repair a torn tail,
985    /// so every write seeks to the end explicitly while the lock is held.
986    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
1080/// Check a batch against current state (plus earlier events in the same batch) before writing.
1081fn 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                // Re-checked under the lock: another process may have purged the prediction
1103                // since `remember` looked at it.
1104                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                // Purging is what a user does after a leak; the reason must not re-leak it.
1159                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    /// The under-lock re-check of an outcome's prediction: `remember` checks it first against the
1228    /// handle's own view, which can be stale, so the batch check is the one that must hold.
1229    #[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        // Purged earlier in the same batch.
1248        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}