use content_addressable::{canonical, ContentAddressable, ContentError, ContentId};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::str::FromStr;
pub use content_addressable::MerkleNode;
pub const JOURNAL_EVENT_SCHEMA_V1: &str = "newt.journal-event/v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum EventKind {
Grant,
Kill,
Reopen,
NoteAppend,
Compression,
ConversationOp,
OrphanedCommit,
}
impl EventKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Grant => "grant",
Self::Kill => "kill",
Self::Reopen => "reopen",
Self::NoteAppend => "note-append",
Self::Compression => "compression",
Self::ConversationOp => "conversation-op",
Self::OrphanedCommit => "orphaned-commit",
}
}
#[must_use]
pub fn all() -> &'static [Self] {
&[
Self::Grant,
Self::Kill,
Self::Reopen,
Self::NoteAppend,
Self::Compression,
Self::ConversationOp,
Self::OrphanedCommit,
]
}
}
impl std::fmt::Display for EventKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JournalEvent {
pub schema: String,
pub kind: EventKind,
pub subject: String,
pub detail: String,
pub via: String,
pub ts_claim: String,
}
impl JournalEvent {
#[must_use]
pub fn new(kind: EventKind, subject: &str, detail: &str, via: &str) -> Self {
Self {
schema: JOURNAL_EVENT_SCHEMA_V1.to_string(),
kind,
subject: subject.to_string(),
detail: detail.to_string(),
via: via.to_string(),
ts_claim: chrono::Utc::now().to_rfc3339(),
}
}
}
impl ContentAddressable for JournalEvent {
fn canonical_form(&self) -> Result<Vec<u8>, ContentError> {
canonical::to_canonical_dagcbor(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(bound(serialize = "T: Serialize", deserialize = "T: DeserializeOwned"))]
pub struct JournalLine<T = JournalEvent> {
pub id: String,
pub node: MerkleNode<T>,
}
impl<T> JournalLine<T> {
#[must_use]
pub fn parent(&self) -> Option<&ContentId> {
let mut parents = self.node.parents().iter();
match (parents.next(), parents.next()) {
(Some(only), None) => Some(only),
_ => None,
}
}
}
impl<T: Serialize> JournalLine<T> {
#[must_use]
pub fn is_intact(&self) -> bool {
ContentId::from_str(&self.id)
.ok()
.and_then(|id| self.node.verify(&id).ok())
.unwrap_or(false)
}
pub fn render_line(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Journal {
head: Option<ContentId>,
}
impl Journal {
#[must_use]
pub fn new() -> Self {
Self { head: None }
}
#[must_use]
pub fn resuming_from(head: ContentId) -> Self {
Self { head: Some(head) }
}
#[must_use]
pub fn head(&self) -> Option<&ContentId> {
self.head.as_ref()
}
pub fn append<T: Serialize>(&mut self, event: T) -> Result<JournalLine<T>, ContentError> {
let node = match self.head {
Some(parent) => MerkleNode::new(event, [parent]),
None => MerkleNode::genesis(event),
};
let id = node.id()?;
self.head = Some(id);
Ok(JournalLine {
id: id.to_string(),
node,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChainBreak {
Edited { index: usize },
Unreadable { index: usize },
BrokenLink { index: usize },
NotAChain { index: usize },
Truncated { expected_head: String },
}
#[must_use]
pub fn verify_chain<T: Serialize>(
lines: &[JournalLine<T>],
expected_head: Option<&str>,
) -> Vec<ChainBreak> {
let mut breaks = Vec::new();
for (index, line) in lines.iter().enumerate() {
if !line.is_intact() {
breaks.push(if ContentId::from_str(&line.id).is_err() {
ChainBreak::Unreadable { index }
} else {
ChainBreak::Edited { index }
});
continue;
}
match (index, line.parent()) {
(0, None) if line.node.parents().is_empty() => {}
(_, Some(parent)) if index > 0 => {
if parent.to_string() != lines[index - 1].id {
breaks.push(ChainBreak::BrokenLink { index });
}
}
_ => breaks.push(ChainBreak::NotAChain { index }),
}
}
if let Some(expected) = expected_head {
if lines.last().map(|line| line.id.as_str()) != Some(expected) {
breaks.push(ChainBreak::Truncated {
expected_head: expected.to_string(),
});
}
}
breaks
}
#[must_use]
pub fn read_jsonl<T: DeserializeOwned>(body: &str) -> Vec<JournalLine<T>> {
body.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| serde_json::from_str(line).ok())
.collect()
}
pub const JOURNAL_PATH_ENV: &str = "NEWT_EVENT_JOURNAL";
#[must_use]
pub fn journal_path() -> Option<PathBuf> {
match std::env::var_os(JOURNAL_PATH_ENV) {
Some(explicit) if explicit.is_empty() => return None,
Some(explicit) => return Some(PathBuf::from(explicit)),
None => {}
}
crate::settings::settings_path().map(|p| p.with_file_name("events.jsonl"))
}
#[must_use]
pub fn head_path(path: &Path) -> PathBuf {
path.with_extension("head")
}
#[must_use]
pub fn read_head(path: &Path) -> Option<String> {
let raw = std::fs::read_to_string(head_path(path)).ok()?;
let trimmed = raw.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
#[must_use]
pub fn resume(path: &Path) -> Journal {
let head = read_head(path).or_else(|| {
let body = std::fs::read_to_string(path).ok()?;
read_jsonl::<serde_json::Value>(&body)
.last()
.map(|line| line.id.clone())
});
head.and_then(|id| ContentId::from_str(&id).ok())
.map_or_else(Journal::new, Journal::resuming_from)
}
pub fn append_to<T: Serialize>(
journal: &mut Journal,
path: &Path,
event: T,
) -> anyhow::Result<JournalLine<T>> {
let line = journal.append(event)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let rendered = line.render_line()?;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
writeln!(file, "{rendered}")?;
file.sync_all()?;
drop(file);
crate::atomic_fs::atomic_write(&head_path(path), line.id.as_bytes())?;
Ok(line)
}
#[must_use]
pub fn pre_chain_path(path: &Path) -> PathBuf {
path.with_extension("pre-chain")
}
pub fn rotate_pre_chain<T: DeserializeOwned>(path: &Path) -> std::io::Result<()> {
if read_head(path).is_some() {
return Ok(());
}
let Ok(body) = std::fs::read_to_string(path) else {
return Ok(());
};
let Some(first) = body.lines().find(|line| !line.trim().is_empty()) else {
return Ok(());
};
if serde_json::from_str::<JournalLine<T>>(first).is_ok() {
return Ok(()); }
std::fs::rename(path, pre_chain_path(path))
}
#[must_use]
pub fn record(kind: EventKind, subject: &str, detail: &str, via: &str) -> Option<JournalLine> {
record_event(JournalEvent::new(kind, subject, detail, via))
}
#[must_use]
pub fn record_event(event: JournalEvent) -> Option<JournalLine> {
let path = journal_path()?;
let mut journal = resume(&path);
append_to(&mut journal, &path, event).ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn event(subject: &str) -> JournalEvent {
JournalEvent::new(EventKind::Grant, subject, "allow", "/settings")
}
fn chain(n: usize) -> (Journal, Vec<JournalLine>) {
let mut journal = Journal::new();
let lines = (0..n)
.map(|i| journal.append(event(&format!("tool-{i}"))).expect("append"))
.collect();
(journal, lines)
}
#[test]
fn a_fresh_journal_starts_with_no_head() {
assert!(Journal::new().head().is_none());
}
#[test]
fn the_first_event_is_genesis_and_later_ones_link_back() {
let (_, lines) = chain(3);
assert!(lines[0].node.parents().is_empty(), "first is genesis");
assert_eq!(
lines[1].parent().map(ToString::to_string),
Some(lines[0].id.clone())
);
assert_eq!(
lines[2].parent().map(ToString::to_string),
Some(lines[1].id.clone())
);
}
#[test]
fn the_head_tracks_the_last_line() {
let (journal, lines) = chain(4);
assert_eq!(
journal.head().map(ToString::to_string),
Some(lines[3].id.clone())
);
}
#[test]
fn a_resumed_journal_links_onto_the_head_it_was_given() {
let (journal, lines) = chain(2);
let head = *journal.head().expect("head");
let mut resumed = Journal::resuming_from(head);
let next = resumed.append(event("after-resume")).expect("append");
assert_eq!(
next.parent().map(ToString::to_string),
Some(lines[1].id.clone())
);
}
#[test]
fn an_intact_chain_reports_no_breaks() {
let (journal, lines) = chain(5);
let head = journal.head().expect("head").to_string();
assert_eq!(verify_chain(&lines, Some(&head)), vec![]);
}
#[test]
fn an_empty_chain_with_no_head_is_intact() {
assert_eq!(verify_chain::<JournalEvent>(&[], None), vec![]);
}
#[test]
fn an_edited_line_is_caught() {
let (_, mut lines) = chain(3);
lines[1].node.payload.detail = "deny".to_string();
assert!(verify_chain(&lines, None).contains(&ChainBreak::Edited { index: 1 }));
}
#[test]
fn a_deleted_line_is_caught() {
let (_, mut lines) = chain(4);
lines.remove(1);
let breaks = verify_chain(&lines, None);
assert!(
breaks.contains(&ChainBreak::BrokenLink { index: 1 }),
"removing a middle line must break the link at the line after it, got {breaks:?}"
);
assert!(
lines.iter().all(JournalLine::is_intact),
"and every surviving line is still individually intact — which is \
exactly why the per-row check is not enough"
);
}
#[test]
fn a_reordered_pair_is_caught() {
let (_, mut lines) = chain(4);
lines.swap(1, 2);
let breaks = verify_chain(&lines, None);
assert!(!breaks.is_empty(), "a reorder must not verify");
assert!(lines.iter().all(JournalLine::is_intact));
}
#[test]
fn a_truncated_tail_is_caught_only_by_the_head_ref() {
let (journal, mut lines) = chain(5);
let head = journal.head().expect("head").to_string();
lines.truncate(3);
assert_eq!(
verify_chain(&lines, None),
vec![],
"the chain alone cannot see its own truncation — this is the \
limit that makes the stored head necessary, not a bug"
);
assert_eq!(
verify_chain(&lines, Some(&head)),
vec![ChainBreak::Truncated {
expected_head: head
}],
);
}
#[test]
fn a_wholesale_replacement_is_caught_by_the_head_ref() {
let (real, _) = chain(3);
let head = real.head().expect("head").to_string();
let (_, forged) = chain(3);
assert_eq!(verify_chain(&forged, None), vec![], "internally consistent");
assert!(matches!(
verify_chain(&forged, Some(&head)).as_slice(),
[ChainBreak::Truncated { .. }]
));
}
#[test]
fn a_line_claiming_an_unparseable_id_is_reported_as_unreadable() {
let (_, mut lines) = chain(2);
lines[0].id = "not-a-content-id".to_string();
assert!(verify_chain(&lines, None).contains(&ChainBreak::Unreadable { index: 0 }));
}
#[test]
fn a_first_line_carrying_a_parent_is_not_a_chain() {
let (_, lines) = chain(2);
let orphan = vec![lines[1].clone()];
assert_eq!(
verify_chain(&orphan, None),
vec![ChainBreak::NotAChain { index: 0 }]
);
}
#[test]
fn a_line_with_several_parents_is_not_a_chain() {
let (_, lines) = chain(3);
let mut forked = lines.clone();
let extra = ContentId::from_str(&lines[0].id).expect("parse");
forked[2].node.parents.insert(extra);
forked[2].id = forked[2].node.id().expect("id").to_string();
assert_eq!(
verify_chain(&forked, None),
vec![ChainBreak::NotAChain { index: 2 }]
);
}
#[test]
fn one_tampered_line_reports_one_break() {
let (_, mut lines) = chain(3);
lines[1].node.payload.subject = "elsewhere".to_string();
assert_eq!(verify_chain(&lines, None).len(), 1);
}
#[test]
fn a_line_round_trips_through_jsonl() {
let (journal, lines) = chain(3);
let body = lines
.iter()
.map(|line| line.render_line().expect("render"))
.collect::<Vec<_>>()
.join("\n");
let read = read_jsonl(&body);
assert_eq!(read, lines);
let head = journal.head().expect("head").to_string();
assert_eq!(verify_chain(&read, Some(&head)), vec![]);
}
#[test]
fn blank_and_unparseable_lines_are_skipped_on_read() {
let (_, lines) = chain(1);
let body = format!(
"\n{}\n\nnot json\n",
lines[0].render_line().expect("render")
);
assert_eq!(read_jsonl(&body), lines);
}
#[test]
fn an_unparseable_middle_line_surfaces_as_a_broken_link() {
let (_, lines) = chain(3);
let body = format!(
"{}\ncorrupted\n{}",
lines[0].render_line().expect("render"),
lines[2].render_line().expect("render"),
);
let read: Vec<JournalLine> = read_jsonl(&body);
assert_eq!(read.len(), 2);
assert_eq!(
verify_chain(&read, None),
vec![ChainBreak::BrokenLink { index: 1 }]
);
}
#[test]
fn every_kind_has_a_distinct_token_and_all_lists_them() {
let tokens: std::collections::BTreeSet<_> =
EventKind::all().iter().map(|k| k.as_str()).collect();
assert_eq!(tokens.len(), EventKind::all().len());
assert_eq!(
EventKind::all().len(),
7,
"the six mutator classes #2009 parked plus OrphanedCommit (#2686 review \
round 4); adding one is a schema decision"
);
}
#[test]
fn the_display_token_matches_the_wire_token() {
for kind in EventKind::all() {
let wire = serde_json::to_string(kind).expect("serialize");
assert_eq!(wire, format!("\"{}\"", kind.as_str()));
assert_eq!(kind.to_string(), kind.as_str());
}
}
#[test]
fn every_event_carries_the_schema_tag() {
assert_eq!(event("x").schema, JOURNAL_EVENT_SCHEMA_V1);
}
#[test]
fn the_kind_is_part_of_the_address() {
let mut grant = event("tool");
let mut kill = grant.clone();
kill.kind = EventKind::Kill;
grant.ts_claim = "fixed".to_string();
kill.ts_claim = "fixed".to_string();
assert_ne!(
grant.content_id().expect("id"),
kill.content_id().expect("id")
);
}
}