use std::io::{Read, Seek, SeekFrom, Write};
use std::path::Path;
use serde::{Deserialize, Serialize};
use super::types::KnowledgeFact;
pub(crate) const JOURNAL_FILE: &str = "journal.jsonl";
const MAX_JOURNAL_BYTES: u64 = 2 * 1024 * 1024;
const TAIL_BYTES: u64 = 4096;
const TRACKED_FIELDS: [&str; 8] = [
"value",
"status",
"validity",
"anchors",
"review",
"valid_until",
"supersedes",
"privacy",
];
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct JournalRow {
pub lsn: u64,
pub at: String,
pub op: String,
pub category: String,
pub key: String,
pub created: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fields: Vec<String>,
pub session: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Change {
pub op: &'static str,
pub category: String,
pub key: String,
pub created: String,
pub fields: Vec<String>,
pub session: String,
}
fn identity(fact: &KnowledgeFact) -> (String, String, String) {
(
fact.category.clone(),
fact.key.clone(),
fact.created_at.to_rfc3339(),
)
}
fn tracked(fact: &KnowledgeFact) -> Vec<serde_json::Value> {
let value = serde_json::to_value(fact).unwrap_or_default();
TRACKED_FIELDS
.iter()
.map(|field| value.get(field).cloned().unwrap_or_default())
.collect()
}
pub fn diff(before: &[KnowledgeFact], after: &[KnowledgeFact]) -> Vec<Change> {
use std::collections::BTreeMap;
let index = |facts: &[KnowledgeFact]| -> BTreeMap<(String, String, String), usize> {
facts
.iter()
.enumerate()
.map(|(i, fact)| (identity(fact), i))
.collect()
};
let (old, new) = (index(before), index(after));
let change = |op, fact: &KnowledgeFact, fields: Vec<String>| Change {
op,
category: fact.category.clone(),
key: crate::core::redaction::redact_text(&fact.key),
created: fact.created_at.to_rfc3339(),
fields,
session: fact.source_session.clone(),
};
let mut changes = Vec::new();
for (id, &i) in &new {
let fact = &after[i];
match old.get(id) {
None => changes.push(change("added", fact, Vec::new())),
Some(&j) => {
let fields: Vec<String> = tracked(&before[j])
.iter()
.zip(tracked(fact))
.zip(TRACKED_FIELDS)
.filter(|((was, now), _)| *was != now)
.map(|(_, name)| name.to_owned())
.collect();
if !fields.is_empty() {
changes.push(change("changed", fact, fields));
}
}
}
}
for (id, &j) in &old {
if !new.contains_key(id) {
changes.push(change("removed", &before[j], Vec::new()));
}
}
changes
.sort_by(|a, b| (&a.category, &a.key, &a.created).cmp(&(&b.category, &b.key, &b.created)));
changes
}
pub(crate) fn append(dir: &Path, changes: &[Change], at: &str) {
if changes.is_empty() {
return;
}
let path = dir.join(JOURNAL_FILE);
let first = head_lsn(dir) + 1;
let mut text = String::new();
for (lsn, change) in (first..).zip(changes) {
let row = JournalRow {
lsn,
at: at.to_owned(),
op: change.op.to_owned(),
category: change.category.clone(),
key: change.key.clone(),
created: change.created.clone(),
fields: change.fields.clone(),
session: change.session.clone(),
};
if let Ok(line) = serde_json::to_string(&row) {
text.push_str(&line);
text.push('\n');
}
}
let appended = crate::core::policy::diagnostics::open_append(&path)
.and_then(|mut file| file.write_all(text.as_bytes()));
if appended.is_err() {
tracing::debug!("[knowledge] journal append failed");
return;
}
trim(&path);
}
pub fn head_lsn(dir: &Path) -> u64 {
let Ok(mut file) = std::fs::File::open(dir.join(JOURNAL_FILE)) else {
return 0;
};
let len = file.metadata().map_or(0, |m| m.len());
let start = len.saturating_sub(TAIL_BYTES);
if file.seek(SeekFrom::Start(start)).is_err() {
return 0;
}
let mut tail = String::new();
if file.read_to_string(&mut tail).is_err() {
return 0;
}
tail.lines()
.rev()
.find_map(|line| serde_json::from_str::<JournalRow>(line).ok())
.map_or(0, |row| row.lsn)
}
pub fn rows_since(dir: &Path, since: u64, limit: usize) -> Vec<JournalRow> {
let Ok(text) = std::fs::read_to_string(dir.join(JOURNAL_FILE)) else {
return Vec::new();
};
text.lines()
.filter_map(|line| serde_json::from_str::<JournalRow>(line).ok())
.filter(|row| row.lsn > since)
.take(limit)
.collect()
}
fn trim(path: &Path) {
if std::fs::metadata(path).map_or(0, |m| m.len()) <= MAX_JOURNAL_BYTES {
return;
}
let Ok(text) = std::fs::read_to_string(path) else {
return;
};
let lines: Vec<&str> = text.lines().collect();
let kept = lines[lines.len() / 2..].join("\n") + "\n";
let _ = crate::core::atomic_fs::try_atomic_write(path, kept.as_bytes(), None);
}
#[cfg(test)]
mod tests {
use super::*;
fn fact(category: &str, key: &str, value: &str, created: &str) -> KnowledgeFact {
serde_json::from_value(serde_json::json!({
"category": category, "key": key, "value": value,
"source_session": "s1", "confidence": 0.9,
"created_at": created, "last_confirmed": created,
}))
.unwrap()
}
const T0: &str = "2026-10-31T10:00:00Z";
const T1: &str = "2026-10-31T11:00:00Z";
#[test]
fn a_diff_names_added_changed_and_removed_facts_by_identity() {
let before = vec![
fact("decision", "db", "postgres", T0),
fact("decision", "queue", "redis", T0),
fact("convention", "naming", "snake", T0),
];
let mut changed = fact("decision", "db", "sqlite", T0);
changed.retrieval_count = 9; let mut read_only = fact("convention", "naming", "snake", T0);
read_only.retrieval_count = 3;
let after = vec![changed, read_only, fact("decision", "db", "duckdb", T1)];
let changes = diff(&before, &after);
let summary: Vec<(&str, &str, &[String])> = changes
.iter()
.map(|c| (c.op, c.key.as_str(), c.fields.as_slice()))
.collect();
assert_eq!(
summary,
vec![
("changed", "db", &["value".to_owned()][..]),
("added", "db", &[][..]),
("removed", "queue", &[][..]),
]
);
}
#[test]
fn rows_are_numbered_on_and_read_back_after_an_lsn() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(head_lsn(dir.path()), 0);
let changes = diff(
&[],
&[
fact("decision", "db", "pg", T0),
fact("decision", "q", "r", T0),
],
);
append(dir.path(), &changes, T0);
append(
dir.path(),
&diff(&[fact("decision", "db", "pg", T0)], &[]),
T1,
);
assert_eq!(head_lsn(dir.path()), 3);
let rows = rows_since(dir.path(), 1, 10);
assert_eq!(rows.iter().map(|r| r.lsn).collect::<Vec<_>>(), vec![2, 3]);
assert_eq!(rows[1].op, "removed");
assert_eq!(rows_since(dir.path(), 0, 1).len(), 1);
let raw = std::fs::read_to_string(dir.path().join(JOURNAL_FILE)).unwrap();
assert!(!raw.contains("\"pg\""));
}
#[test]
fn a_secret_in_a_key_is_redacted_in_the_journal() {
let key = concat!("deploy-", "gh", "p_", "Zq8LmW3vXc9TbN2rKp7YdF4sHj6Ga1Ue");
let changes = diff(&[], &[fact("decision", key, "v", T0)]);
assert!(
!changes[0].key.contains("Zq8LmW3vXc9TbN2rKp7Y"),
"{}",
changes[0].key
);
}
#[test]
fn a_trimmed_journal_keeps_its_newest_rows_and_numbering() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(JOURNAL_FILE);
let row = |lsn: u64| {
serde_json::to_string(&JournalRow {
lsn,
at: T0.into(),
op: "added".into(),
category: "c".into(),
key: "k".repeat(200),
created: T0.into(),
fields: Vec::new(),
session: "s".into(),
})
.unwrap()
};
let lines: Vec<String> = (1..=10_000).map(row).collect();
std::fs::write(&path, lines.join("\n") + "\n").unwrap();
trim(&path);
assert!(std::fs::metadata(&path).unwrap().len() <= MAX_JOURNAL_BYTES);
assert_eq!(head_lsn(dir.path()), 10_000);
assert_eq!(rows_since(dir.path(), 0, 1)[0].lsn, 5_001);
}
}