1use std::io::{Read, Seek, SeekFrom, Write};
21use std::path::Path;
22
23use serde::{Deserialize, Serialize};
24
25use super::types::KnowledgeFact;
26
27pub(crate) const JOURNAL_FILE: &str = "journal.jsonl";
28const MAX_JOURNAL_BYTES: u64 = 2 * 1024 * 1024;
30const TAIL_BYTES: u64 = 4096;
32const TRACKED_FIELDS: [&str; 8] = [
35 "value",
36 "status",
37 "validity",
38 "anchors",
39 "review",
40 "valid_until",
41 "supersedes",
42 "privacy",
43];
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct JournalRow {
48 pub lsn: u64,
49 pub at: String,
50 pub op: String,
52 pub category: String,
53 pub key: String,
54 pub created: String,
57 #[serde(default, skip_serializing_if = "Vec::is_empty")]
58 pub fields: Vec<String>,
59 pub session: String,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Change {
65 pub op: &'static str,
66 pub category: String,
67 pub key: String,
68 pub created: String,
69 pub fields: Vec<String>,
70 pub session: String,
71}
72
73fn identity(fact: &KnowledgeFact) -> (String, String, String) {
74 (
75 fact.category.clone(),
76 fact.key.clone(),
77 fact.created_at.to_rfc3339(),
78 )
79}
80
81fn tracked(fact: &KnowledgeFact) -> Vec<serde_json::Value> {
82 let value = serde_json::to_value(fact).unwrap_or_default();
83 TRACKED_FIELDS
84 .iter()
85 .map(|field| value.get(field).cloned().unwrap_or_default())
86 .collect()
87}
88
89pub fn diff(before: &[KnowledgeFact], after: &[KnowledgeFact]) -> Vec<Change> {
91 use std::collections::BTreeMap;
92 let index = |facts: &[KnowledgeFact]| -> BTreeMap<(String, String, String), usize> {
93 facts
94 .iter()
95 .enumerate()
96 .map(|(i, fact)| (identity(fact), i))
97 .collect()
98 };
99 let (old, new) = (index(before), index(after));
100 let change = |op, fact: &KnowledgeFact, fields: Vec<String>| Change {
101 op,
102 category: fact.category.clone(),
103 key: crate::core::redaction::redact_text(&fact.key),
104 created: fact.created_at.to_rfc3339(),
105 fields,
106 session: fact.source_session.clone(),
107 };
108 let mut changes = Vec::new();
109 for (id, &i) in &new {
110 let fact = &after[i];
111 match old.get(id) {
112 None => changes.push(change("added", fact, Vec::new())),
113 Some(&j) => {
114 let fields: Vec<String> = tracked(&before[j])
115 .iter()
116 .zip(tracked(fact))
117 .zip(TRACKED_FIELDS)
118 .filter(|((was, now), _)| *was != now)
119 .map(|(_, name)| name.to_owned())
120 .collect();
121 if !fields.is_empty() {
122 changes.push(change("changed", fact, fields));
123 }
124 }
125 }
126 }
127 for (id, &j) in &old {
128 if !new.contains_key(id) {
129 changes.push(change("removed", &before[j], Vec::new()));
130 }
131 }
132 changes
133 .sort_by(|a, b| (&a.category, &a.key, &a.created).cmp(&(&b.category, &b.key, &b.created)));
134 changes
135}
136
137pub(crate) fn append(dir: &Path, changes: &[Change], at: &str) {
141 if changes.is_empty() {
142 return;
143 }
144 let path = dir.join(JOURNAL_FILE);
145 let first = head_lsn(dir) + 1;
146 let mut text = String::new();
147 for (lsn, change) in (first..).zip(changes) {
148 let row = JournalRow {
149 lsn,
150 at: at.to_owned(),
151 op: change.op.to_owned(),
152 category: change.category.clone(),
153 key: change.key.clone(),
154 created: change.created.clone(),
155 fields: change.fields.clone(),
156 session: change.session.clone(),
157 };
158 if let Ok(line) = serde_json::to_string(&row) {
159 text.push_str(&line);
160 text.push('\n');
161 }
162 }
163 let appended = crate::core::policy::diagnostics::open_append(&path)
164 .and_then(|mut file| file.write_all(text.as_bytes()));
165 if appended.is_err() {
166 tracing::debug!("[knowledge] journal append failed");
167 return;
168 }
169 trim(&path);
170}
171
172pub fn head_lsn(dir: &Path) -> u64 {
174 let Ok(mut file) = std::fs::File::open(dir.join(JOURNAL_FILE)) else {
175 return 0;
176 };
177 let len = file.metadata().map_or(0, |m| m.len());
178 let start = len.saturating_sub(TAIL_BYTES);
179 if file.seek(SeekFrom::Start(start)).is_err() {
180 return 0;
181 }
182 let mut tail = String::new();
183 if file.read_to_string(&mut tail).is_err() {
184 return 0;
185 }
186 tail.lines()
187 .rev()
188 .find_map(|line| serde_json::from_str::<JournalRow>(line).ok())
189 .map_or(0, |row| row.lsn)
190}
191
192pub fn rows_since(dir: &Path, since: u64, limit: usize) -> Vec<JournalRow> {
194 let Ok(text) = std::fs::read_to_string(dir.join(JOURNAL_FILE)) else {
195 return Vec::new();
196 };
197 text.lines()
198 .filter_map(|line| serde_json::from_str::<JournalRow>(line).ok())
199 .filter(|row| row.lsn > since)
200 .take(limit)
201 .collect()
202}
203
204fn trim(path: &Path) {
206 if std::fs::metadata(path).map_or(0, |m| m.len()) <= MAX_JOURNAL_BYTES {
207 return;
208 }
209 let Ok(text) = std::fs::read_to_string(path) else {
210 return;
211 };
212 let lines: Vec<&str> = text.lines().collect();
213 let kept = lines[lines.len() / 2..].join("\n") + "\n";
214 let _ = crate::core::atomic_fs::try_atomic_write(path, kept.as_bytes(), None);
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 fn fact(category: &str, key: &str, value: &str, created: &str) -> KnowledgeFact {
222 serde_json::from_value(serde_json::json!({
223 "category": category, "key": key, "value": value,
224 "source_session": "s1", "confidence": 0.9,
225 "created_at": created, "last_confirmed": created,
226 }))
227 .unwrap()
228 }
229
230 const T0: &str = "2026-10-31T10:00:00Z";
231 const T1: &str = "2026-10-31T11:00:00Z";
232
233 #[test]
234 fn a_diff_names_added_changed_and_removed_facts_by_identity() {
235 let before = vec![
236 fact("decision", "db", "postgres", T0),
237 fact("decision", "queue", "redis", T0),
238 fact("convention", "naming", "snake", T0),
239 ];
240 let mut changed = fact("decision", "db", "sqlite", T0);
241 changed.retrieval_count = 9; let mut read_only = fact("convention", "naming", "snake", T0);
243 read_only.retrieval_count = 3;
244 let after = vec![changed, read_only, fact("decision", "db", "duckdb", T1)];
245 let changes = diff(&before, &after);
246 let summary: Vec<(&str, &str, &[String])> = changes
247 .iter()
248 .map(|c| (c.op, c.key.as_str(), c.fields.as_slice()))
249 .collect();
250 assert_eq!(
251 summary,
252 vec![
253 ("changed", "db", &["value".to_owned()][..]),
254 ("added", "db", &[][..]),
255 ("removed", "queue", &[][..]),
256 ]
257 );
258 }
259
260 #[test]
261 fn rows_are_numbered_on_and_read_back_after_an_lsn() {
262 let dir = tempfile::tempdir().unwrap();
263 assert_eq!(head_lsn(dir.path()), 0);
264 let changes = diff(
265 &[],
266 &[
267 fact("decision", "db", "pg", T0),
268 fact("decision", "q", "r", T0),
269 ],
270 );
271 append(dir.path(), &changes, T0);
272 append(
273 dir.path(),
274 &diff(&[fact("decision", "db", "pg", T0)], &[]),
275 T1,
276 );
277 assert_eq!(head_lsn(dir.path()), 3);
278 let rows = rows_since(dir.path(), 1, 10);
279 assert_eq!(rows.iter().map(|r| r.lsn).collect::<Vec<_>>(), vec![2, 3]);
280 assert_eq!(rows[1].op, "removed");
281 assert_eq!(rows_since(dir.path(), 0, 1).len(), 1);
282 let raw = std::fs::read_to_string(dir.path().join(JOURNAL_FILE)).unwrap();
284 assert!(!raw.contains("\"pg\""));
285 }
286
287 #[test]
288 fn a_secret_in_a_key_is_redacted_in_the_journal() {
289 let key = concat!("deploy-", "gh", "p_", "Zq8LmW3vXc9TbN2rKp7YdF4sHj6Ga1Ue");
290 let changes = diff(&[], &[fact("decision", key, "v", T0)]);
291 assert!(
292 !changes[0].key.contains("Zq8LmW3vXc9TbN2rKp7Y"),
293 "{}",
294 changes[0].key
295 );
296 }
297
298 #[test]
299 fn a_trimmed_journal_keeps_its_newest_rows_and_numbering() {
300 let dir = tempfile::tempdir().unwrap();
301 let path = dir.path().join(JOURNAL_FILE);
302 let row = |lsn: u64| {
303 serde_json::to_string(&JournalRow {
304 lsn,
305 at: T0.into(),
306 op: "added".into(),
307 category: "c".into(),
308 key: "k".repeat(200),
309 created: T0.into(),
310 fields: Vec::new(),
311 session: "s".into(),
312 })
313 .unwrap()
314 };
315 let lines: Vec<String> = (1..=10_000).map(row).collect();
316 std::fs::write(&path, lines.join("\n") + "\n").unwrap();
317 trim(&path);
318 assert!(std::fs::metadata(&path).unwrap().len() <= MAX_JOURNAL_BYTES);
319 assert_eq!(head_lsn(dir.path()), 10_000);
320 assert_eq!(rows_since(dir.path(), 0, 1)[0].lsn, 5_001);
321 }
322}