1use crate::checkpoint::Checkpoint;
63use crate::fold::{FoldedRecord, SyncState};
64use crate::journal::OplogJournal;
65use crate::oplog::{verify_log, ChainError, Hlc, OpRecord};
66use serde::{Deserialize, Serialize};
67use serde_json::{json, Value};
68use std::collections::{BTreeMap, BTreeSet};
69use std::fmt;
70use std::fs::{self, File};
71use std::io::Write;
72use std::path::{Path, PathBuf};
73
74pub const RUNS_MAX_PER_AGENT: usize = 50;
77pub const RUNS_MAX_AGE_MS: u64 = 30 * 24 * 60 * 60 * 1000;
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum RetentionRule {
85 KeepAll,
88 LastN { n: usize },
91 MaxAgeMs { max_age_ms: u64 },
94 PerAgentWithAge { max_per_agent: usize, max_age_ms: u64 },
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct RetentionPolicy {
106 pub rules: BTreeMap<String, RetentionRule>,
107 pub default_rule: RetentionRule,
108}
109
110impl Default for RetentionPolicy {
111 fn default() -> Self {
112 Self::keep_all()
113 }
114}
115
116impl RetentionPolicy {
117 pub fn keep_all() -> Self {
120 Self {
121 rules: BTreeMap::new(),
122 default_rule: RetentionRule::KeepAll,
123 }
124 }
125
126 pub fn proposal_default(conversation_last_n: usize, trajectory_max_age_ms: u64) -> Self {
135 let mut rules = BTreeMap::new();
136 rules.insert("conversation".to_string(), RetentionRule::LastN { n: conversation_last_n });
137 rules.insert(
138 "run".to_string(),
139 RetentionRule::PerAgentWithAge {
140 max_per_agent: RUNS_MAX_PER_AGENT,
141 max_age_ms: RUNS_MAX_AGE_MS,
142 },
143 );
144 rules.insert(
145 "trajectory".to_string(),
146 RetentionRule::MaxAgeMs { max_age_ms: trajectory_max_age_ms },
147 );
148 rules.insert("knowledge".to_string(), RetentionRule::KeepAll);
149 rules.insert("skill".to_string(), RetentionRule::KeepAll);
150 rules.insert("routing".to_string(), RetentionRule::KeepAll);
151 Self { rules, default_rule: RetentionRule::KeepAll }
152 }
153
154 pub fn rule_for(&self, surface_tag: &str) -> &RetentionRule {
155 self.rules.get(surface_tag).unwrap_or(&self.default_rule)
156 }
157}
158
159#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
163pub struct RetentionReport {
164 pub dropped: BTreeMap<String, usize>,
165 pub tombstoned: Vec<(String, String)>,
166}
167
168fn value_ts(payload: &Value) -> Option<u64> {
169 payload
170 .get("timestamp")
171 .and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f as u64)))
172}
173
174fn payload_ts(record: &FoldedRecord) -> Option<u64> {
175 value_ts(&record.payload)
176}
177
178pub fn as_of_from_ops(ops: &[OpRecord]) -> u64 {
186 ops.iter().filter_map(|op| value_ts(&op.payload)).max().unwrap_or(0)
187}
188
189fn within_age(record: &FoldedRecord, as_of_ms: u64, max_age_ms: u64) -> bool {
190 match payload_ts(record) {
191 Some(ts) => as_of_ms.saturating_sub(ts) <= max_age_ms,
193 None => true,
195 }
196}
197
198fn recency_sorted(
201 entries: &BTreeMap<String, FoldedRecord>,
202) -> Vec<(&String, &FoldedRecord)> {
203 let mut sorted: Vec<(&String, &FoldedRecord)> = entries.iter().collect();
204 sorted.sort_by(|(_, a), (_, b)| {
205 (payload_ts(a).unwrap_or(u64::MAX), &a.hlc, &a.op_id).cmp(&(
206 payload_ts(b).unwrap_or(u64::MAX),
207 &b.hlc,
208 &b.op_id,
209 ))
210 });
211 sorted
212}
213
214fn select_retained(
215 entries: &BTreeMap<String, FoldedRecord>,
216 rule: &RetentionRule,
217 as_of_ms: u64,
218) -> BTreeSet<String> {
219 match rule {
220 RetentionRule::KeepAll => entries.keys().cloned().collect(),
221 RetentionRule::LastN { n } => recency_sorted(entries)
222 .into_iter()
223 .rev()
224 .take(*n)
225 .map(|(k, _)| k.clone())
226 .collect(),
227 RetentionRule::MaxAgeMs { max_age_ms } => entries
228 .iter()
229 .filter(|(_, r)| within_age(r, as_of_ms, *max_age_ms))
230 .map(|(k, _)| k.clone())
231 .collect(),
232 RetentionRule::PerAgentWithAge { max_per_agent, max_age_ms } => {
233 let mut per_agent_rank: BTreeMap<&str, usize> = BTreeMap::new();
236 let mut keep = BTreeSet::new();
237 for (key, record) in recency_sorted(entries).into_iter().rev() {
238 let agent = record
239 .payload
240 .get("agent_id")
241 .and_then(Value::as_str)
242 .unwrap_or("");
243 let rank = per_agent_rank.entry(agent).or_insert(0);
244 let over_count = *rank >= *max_per_agent;
245 *rank += 1;
246 if !over_count && within_age(record, as_of_ms, *max_age_ms) {
247 keep.insert(key.clone());
248 }
249 }
250 keep
251 }
252 }
253}
254
255fn entity_id<'a>(key: &'a str, record: &'a FoldedRecord) -> Option<&'a str> {
258 key.strip_prefix("id:")
259 .or_else(|| record.payload.get("id").and_then(Value::as_str))
260}
261
262pub fn is_tombstone(record: &FoldedRecord) -> bool {
265 record
266 .payload
267 .get("tombstone")
268 .and_then(Value::as_bool)
269 .unwrap_or(false)
270}
271
272fn tombstone_of(record: &FoldedRecord, id: &str) -> FoldedRecord {
277 FoldedRecord {
278 op_id: record.op_id.clone(),
279 hlc: record.hlc.clone(),
280 payload: json!({"id": id, "tombstone": true}),
281 }
282}
283
284pub fn apply_retention(
295 state: &SyncState,
296 policy: &RetentionPolicy,
297 as_of_ms: u64,
298) -> Result<(SyncState, RetentionReport), CompactError> {
299 let routing_tag = crate::oplog::Surface::Routing.tag();
310 debug_assert!(crate::oplog::Surface::Routing.is_replay_stream());
311 if state.logs.contains_key(&routing_tag)
312 && policy.rule_for(&routing_tag) != &RetentionRule::KeepAll
313 {
314 return Err(CompactError::EventStreamRetention { surface: routing_tag });
315 }
316
317 if policy.rule_for(&crate::oplog::Surface::Intent.tag()) != &RetentionRule::KeepAll {
327 return Err(CompactError::IntentRetention);
328 }
329
330 let mut retained = state.clone();
331 let mut report = RetentionReport::default();
332 for (tag, entries) in &state.logs {
333 let rule = policy.rule_for(tag);
334 if rule == &RetentionRule::KeepAll {
335 continue;
336 }
337 let live: BTreeMap<String, FoldedRecord> = entries
340 .iter()
341 .filter(|(_, record)| !is_tombstone(record))
342 .map(|(key, record)| (key.clone(), record.clone()))
343 .collect();
344 let keep = select_retained(&live, rule, as_of_ms);
345 let surface = retained.logs.get_mut(tag).expect("cloned from state");
346 for (key, record) in &live {
347 if keep.contains(key) {
348 continue;
349 }
350 match entity_id(key, record) {
351 Some(id) => {
352 surface.insert(key.clone(), tombstone_of(record, id));
353 report.tombstoned.push((tag.clone(), key.clone()));
354 }
355 None => {
356 surface.remove(key);
357 *report.dropped.entry(tag.clone()).or_insert(0) += 1;
358 }
359 }
360 }
361 }
362 report.tombstoned.sort();
363 Ok((retained, report))
364}
365
366#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
379pub struct AckTable {
380 acked: BTreeMap<String, Hlc>,
381}
382
383impl AckTable {
384 pub fn new() -> Self {
385 Self::default()
386 }
387
388 pub fn ack(&mut self, device_id: impl Into<String>, frontier: Hlc) -> bool {
392 let device_id = device_id.into();
393 match self.acked.get(&device_id) {
394 Some(current) if frontier <= *current => false,
395 _ => {
396 self.acked.insert(device_id, frontier);
397 true
398 }
399 }
400 }
401
402 pub fn get(&self, device_id: &str) -> Option<&Hlc> {
403 self.acked.get(device_id)
404 }
405
406 pub fn devices(&self) -> impl Iterator<Item = &str> {
407 self.acked.keys().map(String::as_str)
408 }
409
410 pub fn stable_frontier(&self) -> Option<&Hlc> {
415 self.acked.values().min()
416 }
417
418 pub fn save(&self, path: &Path) -> std::io::Result<()> {
422 if let Some(parent) = path.parent() {
423 if !parent.as_os_str().is_empty() {
424 fs::create_dir_all(parent)?;
425 }
426 }
427 let tmp_path = {
428 let mut s = path.as_os_str().to_owned();
429 s.push(".tmp");
430 PathBuf::from(s)
431 };
432 {
433 let mut tmp = File::create(&tmp_path)?;
434 tmp.write_all(
435 serde_json::to_string(self)
436 .map_err(std::io::Error::other)?
437 .as_bytes(),
438 )?;
439 tmp.sync_all()?;
440 }
441 fs::rename(&tmp_path, path)
442 }
443
444 pub fn load(path: &Path) -> std::io::Result<Self> {
448 match fs::read_to_string(path) {
449 Ok(raw) => serde_json::from_str(&raw).map_err(std::io::Error::other),
450 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
451 Err(e) => Err(e),
452 }
453 }
454}
455
456#[derive(Debug)]
459pub enum CompactError {
460 Chain(ChainError),
463 NothingAcked,
466 UnackedDevice { device_id: String },
469 EventStreamRetention { surface: String },
473 IntentRetention,
477 TruncatedJournal { checkpoint_hash: String },
482 Io(std::io::Error),
483}
484
485impl fmt::Display for CompactError {
486 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
487 match self {
488 CompactError::Chain(e) => write!(f, "compaction refused: log does not verify: {e}"),
489 CompactError::NothingAcked => {
490 write!(f, "compaction refused: no acked frontier exists (empty ack table)")
491 }
492 CompactError::UnackedDevice { device_id } => write!(
493 f,
494 "compaction refused: device {device_id} appears in the log but has no acked \
495 frontier — its ops may include state no other replica has folded"
496 ),
497 CompactError::EventStreamRetention { surface } => write!(
498 f,
499 "retention policy for event-stream surface {surface} must be keep_all — \
500 observation multisets replay from genesis and cannot be trimmed"
501 ),
502 CompactError::IntentRetention => write!(
503 f,
504 "retention policy for the leased `intent` surface must be keep_all — the \
505 committed-run idempotency oracle must survive compaction and cannot be trimmed"
506 ),
507 CompactError::TruncatedJournal { checkpoint_hash } => write!(
508 f,
509 "compaction refused: journal already truncated below checkpoint \
510 {checkpoint_hash} — re-planning from the tail alone would drop that \
511 checkpoint's state (recompaction over a checkpoint base is a later slice)"
512 ),
513 CompactError::Io(e) => write!(f, "compaction io error: {e}"),
514 }
515 }
516}
517
518impl std::error::Error for CompactError {}
519
520#[derive(Debug)]
523pub struct CompactionPlan {
524 pub checkpoint: Checkpoint,
527 pub retained_ops: Vec<OpRecord>,
529 pub dropped_ops: usize,
532 pub frontier: Hlc,
534 pub as_of_ms: u64,
537 pub retention: RetentionReport,
539}
540
541pub fn plan_compaction(
552 ops: &[OpRecord],
553 acks: &AckTable,
554 policy: &RetentionPolicy,
555 as_of_ms: Option<u64>,
556) -> Result<CompactionPlan, CompactError> {
557 verify_log(ops).map_err(CompactError::Chain)?;
558 let frontier = acks
559 .stable_frontier()
560 .cloned()
561 .ok_or(CompactError::NothingAcked)?;
562 for op in ops {
563 if acks.get(&op.device_id).is_none() {
564 return Err(CompactError::UnackedDevice { device_id: op.device_id.clone() });
565 }
566 }
567
568 let (below, retained_ops): (Vec<OpRecord>, Vec<OpRecord>) =
569 ops.iter().cloned().partition(|op| op.hlc <= frontier);
570 let as_of_ms = as_of_ms.unwrap_or_else(|| as_of_from_ops(&below));
571
572 let exact = Checkpoint::from_ops(&below).map_err(CompactError::Chain)?;
575 let (retained_state, retention) = apply_retention(&exact.state, policy, as_of_ms)?;
576 let checkpoint = Checkpoint::assemble(exact.frontier, exact.scopes, retained_state);
577
578 Ok(CompactionPlan {
579 checkpoint,
580 retained_ops,
581 dropped_ops: below.len(),
582 frontier,
583 as_of_ms,
584 retention,
585 })
586}
587
588#[derive(Debug)]
590pub struct CompactionOutcome {
591 pub checkpoint_path: Option<PathBuf>,
597 pub plan: CompactionPlan,
598}
599
600pub fn compact_and_truncate(
623 journal: &mut OplogJournal,
624 checkpoint_dir: &Path,
625 acks: &AckTable,
626 policy: &RetentionPolicy,
627 as_of_ms: Option<u64>,
628) -> Result<CompactionOutcome, CompactError> {
629 let (marker, ops) =
630 OplogJournal::load_with_marker(journal.path()).map_err(CompactError::Io)?;
631 if let Some(marker) = marker {
632 return Err(CompactError::TruncatedJournal { checkpoint_hash: marker.checkpoint_hash });
633 }
634 let plan = plan_compaction(&ops, acks, policy, as_of_ms)?;
635 if plan.dropped_ops == 0 {
636 return Ok(CompactionOutcome { checkpoint_path: None, plan });
639 }
640 let checkpoint_path = plan.checkpoint.save(checkpoint_dir).map_err(CompactError::Io)?;
642 journal
643 .truncate_to(&plan.retained_ops, &plan.checkpoint.checkpoint_hash)
644 .map_err(CompactError::Io)?;
645 Ok(CompactionOutcome { checkpoint_path: Some(checkpoint_path), plan })
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651 use crate::fold::fold;
652 use crate::oplog::{DeviceLog, Scope, Surface};
653 use serde_json::json;
654
655 fn hlc(wall_ms: u64, device: &str) -> Hlc {
656 Hlc { wall_ms, counter: 0, device_id: device.into() }
657 }
658
659 #[test]
660 fn ack_table_is_monotone_only_and_min_frontier() {
661 let mut acks = AckTable::new();
662 assert_eq!(acks.stable_frontier(), None);
663 assert!(acks.ack("a", hlc(5, "a")));
664 assert!(acks.ack("b", hlc(9, "b")));
665 assert_eq!(acks.stable_frontier(), Some(&hlc(5, "a")), "min over devices");
666
667 assert!(!acks.ack("b", hlc(3, "b")));
669 assert!(!acks.ack("b", hlc(9, "b")), "equal is not an advance");
670 assert_eq!(acks.get("b"), Some(&hlc(9, "b")));
671 assert!(acks.ack("b", hlc(12, "b")));
672 assert_eq!(acks.get("b"), Some(&hlc(12, "b")));
673 }
674
675 #[test]
676 fn ack_table_persists_atomically_and_loads_missing_as_empty() {
677 let dir = tempfile::tempdir().unwrap();
678 let path = dir.path().join("nested").join("acks.json");
679 assert_eq!(AckTable::load(&path).unwrap(), AckTable::new(), "missing → empty");
680
681 let mut acks = AckTable::new();
682 acks.ack("a", hlc(5, "a"));
683 acks.ack("b", hlc(9, "b"));
684 acks.save(&path).unwrap();
685 assert_eq!(AckTable::load(&path).unwrap(), acks);
686 assert!(!path.parent().unwrap().join("acks.json.tmp").exists());
688 }
689
690 fn simple_ops() -> Vec<OpRecord> {
692 let mut a = DeviceLog::new("a");
693 let mut b = DeviceLog::new("b");
694 let mut ops = vec![
695 a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f1"})),
696 a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f2"})),
697 a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f3"})),
698 ];
699 for op in &ops {
700 b.observe(&op.hlc);
701 }
702 ops.push(b.append(Scope::Personal, Surface::Knowledge, json!({"id": "f4"})));
703 ops
704 }
705
706 #[test]
707 fn compaction_refuses_without_acks() {
708 let ops = simple_ops();
709 let policy = RetentionPolicy::keep_all();
710 assert!(matches!(
711 plan_compaction(&ops, &AckTable::new(), &policy, None),
712 Err(CompactError::NothingAcked)
713 ));
714
715 let mut acks = AckTable::new();
718 acks.ack("a", ops[2].hlc.clone());
719 assert!(matches!(
720 plan_compaction(&ops, &acks, &policy, None),
721 Err(CompactError::UnackedDevice { .. })
722 ));
723 }
724
725 #[test]
726 fn compaction_never_drops_above_a_lagging_ack() {
727 let ops = simple_ops();
728 let mut acks = AckTable::new();
729 acks.ack("b", ops[3].hlc.clone());
731 acks.ack("a", ops[1].hlc.clone());
732
733 let plan = plan_compaction(&ops, &acks, &RetentionPolicy::keep_all(), None).unwrap();
734 assert_eq!(plan.frontier, ops[1].hlc, "stable frontier = the lagging device's ack");
735 assert_eq!(plan.dropped_ops, 2, "only ops ≤ the lagging frontier drop");
736 assert_eq!(plan.retained_ops.len(), 2);
737 assert!(
738 plan.retained_ops.iter().all(|op| op.hlc > plan.frontier),
739 "everything a device hasn't seen stays in the journal"
740 );
741 }
742
743 #[test]
744 fn retention_conversations_last_n_by_timestamp() {
745 let mut dev = DeviceLog::new("a");
746 let ops: Vec<OpRecord> = (0..5)
747 .map(|i| {
748 dev.append(
749 Scope::Personal,
750 Surface::Conversation,
751 json!({"speaker": "u", "text": format!("t{i}"), "timestamp": 100 + i}),
752 )
753 })
754 .collect();
755 let state = fold(&ops);
756 let policy = RetentionPolicy::proposal_default(2, u64::MAX);
757 let (retained, report) = apply_retention(&state, &policy, 1_000).unwrap();
758 let tag = Surface::Conversation.tag();
759 let texts: Vec<String> = retained
760 .log_entries(&tag)
761 .iter()
762 .map(|r| r.payload["text"].as_str().unwrap().to_string())
763 .collect();
764 assert_eq!(texts, vec!["t3", "t4"], "last 2 turns by timestamp survive");
765 assert_eq!(report.dropped[&tag], 3);
766 }
767
768 #[test]
769 fn retention_runs_per_agent_and_age_matches_run_store_gc() {
770 assert_eq!(RUNS_MAX_PER_AGENT, 50, "parity with run_store DEFAULT_MAX_RUNS_PER_AGENT");
771 assert_eq!(RUNS_MAX_AGE_MS, 30 * 24 * 60 * 60 * 1000, "parity with DEFAULT_MAX_AGE_DAYS");
772
773 let mut dev = DeviceLog::new("a");
776 let mut ops = Vec::new();
777 for (id, agent, ts) in [
778 ("r1", "milo", 10u64), ("r2", "milo", 60), ("r3", "milo", 70), ("r4", "other", 10), ("r5", "other", 80), ] {
784 ops.push(dev.append(
785 Scope::Personal,
786 Surface::Run,
787 json!({"id": id, "agent_id": agent, "timestamp": ts}),
788 ));
789 }
790 let state = fold(&ops);
791 let mut policy = RetentionPolicy::keep_all();
792 policy.rules.insert(
793 "run".to_string(),
794 RetentionRule::PerAgentWithAge { max_per_agent: 2, max_age_ms: 50 },
795 );
796 let (retained, report) = apply_retention(&state, &policy, 100).unwrap();
797 let entries = retained.log_entries(&Surface::Run.tag());
798 let kept: Vec<&str> = entries
799 .iter()
800 .filter(|r| !is_tombstone(r))
801 .map(|r| r.payload["id"].as_str().unwrap())
802 .collect();
803 assert_eq!(kept, vec!["r2", "r3", "r5"]);
804 let stubs: Vec<&str> = entries
806 .iter()
807 .filter(|r| is_tombstone(r))
808 .map(|r| r.payload["id"].as_str().unwrap())
809 .collect();
810 assert_eq!(stubs, vec!["r1", "r4"]);
811 assert_eq!(report.tombstoned.len(), 2);
812 assert!(report.dropped.is_empty(), "id-bearing entries are stubbed, never erased");
813 }
814
815 #[test]
816 fn retention_trajectories_by_age_and_undated_never_dropped() {
817 let mut dev = DeviceLog::new("a");
818 let ops = vec![
819 dev.append(Scope::Personal, Surface::Trajectory, json!({"id": "old", "timestamp": 10})),
820 dev.append(Scope::Personal, Surface::Trajectory, json!({"id": "new", "timestamp": 90})),
821 dev.append(Scope::Personal, Surface::Trajectory, json!({"id": "undated"})),
822 ];
823 let state = fold(&ops);
824 let mut policy = RetentionPolicy::keep_all();
825 policy
826 .rules
827 .insert("trajectory".to_string(), RetentionRule::MaxAgeMs { max_age_ms: 30 });
828 let (retained, _) = apply_retention(&state, &policy, 100).unwrap();
829 let entries = retained.log_entries(&Surface::Trajectory.tag());
830 let kept: Vec<&str> = entries
831 .iter()
832 .filter(|r| !is_tombstone(r))
833 .map(|r| r.payload["id"].as_str().unwrap())
834 .collect();
835 assert!(kept.contains(&"new"));
836 assert!(kept.contains(&"undated"), "undated data is never silently age-dropped");
837 assert!(!kept.contains(&"old"));
838 assert!(entries
840 .iter()
841 .any(|r| is_tombstone(r) && r.payload["id"] == json!("old")));
842 }
843
844 #[test]
845 fn knowledge_and_skills_keep_all_under_the_proposal_default() {
846 let mut dev = DeviceLog::new("a");
847 let ops = vec![
848 dev.append(Scope::Personal, Surface::Knowledge, json!({"id": "f1", "timestamp": 1})),
849 dev.append(Scope::Personal, Surface::Skill, json!({"id": "s1", "timestamp": 1})),
850 ];
851 let state = fold(&ops);
852 let (retained, report) =
854 apply_retention(&state, &RetentionPolicy::proposal_default(1, 1), u64::MAX).unwrap();
855 assert_eq!(retained.logs[&Surface::Knowledge.tag()].len(), 1);
856 assert_eq!(retained.logs[&Surface::Skill.tag()].len(), 1);
857 assert!(report.dropped.is_empty());
858 }
859
860 #[test]
861 fn event_stream_retention_is_rejected() {
862 let mut dev = DeviceLog::new("a");
863 let ops = vec![
864 dev.append(Scope::Personal, Surface::Routing, json!({"sample": 1.0})),
865 dev.append(Scope::Personal, Surface::Routing, json!({"sample": 0.0})),
866 ];
867 let state = fold(&ops);
868 let mut policy = RetentionPolicy::keep_all();
869 policy
870 .rules
871 .insert("routing".to_string(), RetentionRule::LastN { n: 1 });
872 assert!(matches!(
873 apply_retention(&state, &policy, 0),
874 Err(CompactError::EventStreamRetention { .. })
875 ));
876 let (retained, _) =
878 apply_retention(&state, &RetentionPolicy::proposal_default(10, 10), u64::MAX).unwrap();
879 assert_eq!(retained.log_entries(&Surface::Routing.tag()).len(), 2);
880 }
881
882 #[test]
883 fn every_dropped_id_bearing_entry_leaves_a_tombstone_stub() {
884 let mut dev = DeviceLog::new("a");
889 let ops = vec![
890 dev.append(Scope::Personal, Surface::Knowledge, json!({"id": "f1", "timestamp": 1})),
891 dev.append(
892 Scope::Personal,
893 Surface::Knowledge,
894 json!({"id": "f2", "timestamp": 2, "supersedes": "f1"}),
895 ),
896 dev.append(
897 Scope::Personal,
898 Surface::Knowledge,
899 json!({"id": "f3", "timestamp": 3, "supersedes": ["f2"]}),
900 ),
901 dev.append(Scope::Personal, Surface::Knowledge, json!({"id": "f0", "timestamp": 0})),
902 ];
903 let state = fold(&ops);
904 let mut policy = RetentionPolicy::keep_all();
905 policy
906 .rules
907 .insert("knowledge".to_string(), RetentionRule::LastN { n: 1 });
908 let (retained, report) = apply_retention(&state, &policy, 10).unwrap();
909 let tag = Surface::Knowledge.tag();
910 let surface = &retained.logs[&tag];
911 assert_eq!(surface["id:f3"].payload["timestamp"], json!(3), "newest stays live");
912 for id in ["f0", "f1", "f2"] {
913 let stub = &surface[&format!("id:{id}")];
914 assert!(is_tombstone(stub), "{id} left a stub");
915 assert_eq!(stub.payload, json!({"id": id, "tombstone": true}), "minimal stub shape");
916 assert_eq!(
917 stub.op_id,
918 state.logs[&tag][&format!("id:{id}")].op_id,
919 "stub keeps the original op identity"
920 );
921 }
922 assert_eq!(report.tombstoned.len(), 3);
923 assert!(report.dropped.is_empty());
924
925 let (again, report2) = apply_retention(&retained, &policy, 10).unwrap();
928 assert_eq!(again, retained);
929 assert!(report2.tombstoned.is_empty());
930 }
931
932 #[test]
933 fn derived_as_of_is_the_max_below_frontier_timestamp() {
934 let mut dev = DeviceLog::new("a");
935 let ops = vec![
936 dev.append(Scope::Personal, Surface::Trajectory, json!({"id": "t1", "timestamp": 40})),
937 dev.append(Scope::Personal, Surface::Trajectory, json!({"id": "t2", "timestamp": 100})),
938 dev.append(Scope::Personal, Surface::Skill, json!({"id": "s1"})), ];
940 assert_eq!(as_of_from_ops(&ops), 100, "max payload timestamp");
941 assert_eq!(as_of_from_ops(&ops[2..]), 0, "no timestamps → 0 (age rules drop nothing)");
942
943 let mut acks = AckTable::new();
947 acks.ack("a", ops[2].hlc.clone());
948 let mut policy = RetentionPolicy::keep_all();
949 policy
950 .rules
951 .insert("trajectory".to_string(), RetentionRule::MaxAgeMs { max_age_ms: 30 });
952 let plan = plan_compaction(&ops, &acks, &policy, None).unwrap();
953 assert_eq!(plan.as_of_ms, 100);
954 let surface = &plan.checkpoint.state.logs[&Surface::Trajectory.tag()];
956 assert!(is_tombstone(&surface["id:t1"]));
957 assert!(!is_tombstone(&surface["id:t2"]));
958 }
959
960 #[test]
961 fn empty_below_frontier_compaction_is_a_no_op() {
962 let dir = tempfile::tempdir().unwrap();
963 let journal_path = dir.path().join("oplog.jsonl");
964 let ckpt_dir = dir.path().join("checkpoints");
965
966 let ops = simple_ops();
969 let mut journal = OplogJournal::open(&journal_path).unwrap();
970 for op in &ops {
971 journal.append(op).unwrap();
972 }
973 let mut acks = AckTable::new();
974 acks.ack("a", Hlc { wall_ms: 0, counter: 0, device_id: "a".into() });
975 acks.ack("b", Hlc { wall_ms: 0, counter: 0, device_id: "b".into() });
976
977 let before = fs::read_to_string(&journal_path).unwrap();
978 let outcome =
979 compact_and_truncate(&mut journal, &ckpt_dir, &acks, &RetentionPolicy::keep_all(), None)
980 .unwrap();
981 assert_eq!(outcome.plan.dropped_ops, 0);
982 assert!(outcome.checkpoint_path.is_none(), "no empty checkpoint file written");
983 assert!(!ckpt_dir.exists(), "checkpoint dir not even created");
984 assert_eq!(
985 fs::read_to_string(&journal_path).unwrap(),
986 before,
987 "journal untouched (no marker, no rewrite)"
988 );
989 assert_eq!(OplogJournal::load(&journal_path).unwrap(), ops);
991 }
992
993 #[test]
994 fn recompacting_an_already_truncated_journal_is_refused() {
995 let dir = tempfile::tempdir().unwrap();
996 let journal_path = dir.path().join("oplog.jsonl");
997 let ckpt_dir = dir.path().join("checkpoints");
998 let ops = simple_ops();
999 let mut journal = OplogJournal::open(&journal_path).unwrap();
1000 for op in &ops {
1001 journal.append(op).unwrap();
1002 }
1003 let mut acks = AckTable::new();
1004 acks.ack("a", ops[1].hlc.clone());
1005 acks.ack("b", ops[1].hlc.clone());
1006 let outcome =
1007 compact_and_truncate(&mut journal, &ckpt_dir, &acks, &RetentionPolicy::keep_all(), None)
1008 .unwrap();
1009 let expected_hash = outcome.plan.checkpoint.checkpoint_hash.clone();
1010
1011 acks.ack("a", ops[3].hlc.clone());
1015 acks.ack("b", ops[3].hlc.clone());
1016 match compact_and_truncate(&mut journal, &ckpt_dir, &acks, &RetentionPolicy::keep_all(), None)
1017 {
1018 Err(CompactError::TruncatedJournal { checkpoint_hash }) => {
1019 assert_eq!(checkpoint_hash, expected_hash)
1020 }
1021 other => panic!("expected TruncatedJournal refusal, got {other:?}"),
1022 }
1023 }
1024
1025 #[test]
1026 fn plan_refuses_an_invalid_log() {
1027 let mut ops = simple_ops();
1028 ops[1].payload = json!({"forged": true});
1029 let mut acks = AckTable::new();
1030 acks.ack("a", ops[2].hlc.clone());
1031 acks.ack("b", ops[3].hlc.clone());
1032 assert!(matches!(
1033 plan_compaction(&ops, &acks, &RetentionPolicy::keep_all(), None),
1034 Err(CompactError::Chain(ChainError::IdMismatch { .. }))
1035 ));
1036 }
1037
1038 #[test]
1039 fn intent_retention_rule_is_rejected() {
1040 use crate::lease::{Intent, IntentStatus};
1044 let mut dev = DeviceLog::new("a");
1045 let ops = vec![dev.append(
1046 Scope::Personal,
1047 Surface::Intent,
1048 Intent::new("milo", "R", 1, IntentStatus::Committed).payload(),
1049 )];
1050 let state = fold(&ops);
1051 let mut policy = RetentionPolicy::keep_all();
1052 policy.rules.insert("intent".to_string(), RetentionRule::LastN { n: 1 });
1053 assert!(matches!(
1054 apply_retention(&state, &policy, 0),
1055 Err(CompactError::IntentRetention)
1056 ));
1057 let (retained, _) = apply_retention(&state, &RetentionPolicy::keep_all(), 0).unwrap();
1059 assert!(retained.committed_run("milo", "R").is_some());
1060 }
1061
1062 #[test]
1063 fn c2_committed_run_survives_a_checkpoint_compaction() {
1064 use crate::fold::fold_onto;
1069 use crate::lease::{Intent, IntentStatus};
1070
1071 let mut a = DeviceLog::new("a");
1072 let mut b = DeviceLog::new("b");
1073 let mut ops = vec![a.append(
1074 Scope::Personal,
1075 Surface::Intent,
1076 Intent::new("milo", "R", 1, IntentStatus::Committed).payload(),
1077 )];
1078 let split = ops.len();
1079 for op in &ops {
1080 b.observe(&op.hlc);
1081 }
1082 ops.push(b.append(
1084 Scope::Personal,
1085 Surface::Intent,
1086 Intent::new("milo", "S", 2, IntentStatus::Pending).payload(),
1087 ));
1088
1089 let frontier = ops[..split].iter().map(|o| o.hlc.clone()).max().unwrap();
1091 let mut acks = AckTable::new();
1092 acks.ack("a", frontier.clone());
1093 acks.ack("b", frontier);
1094 let plan = plan_compaction(&ops, &acks, &RetentionPolicy::keep_all(), None).unwrap();
1095 assert_eq!(plan.dropped_ops, split, "committed R is below the frontier, dropped from journal");
1096
1097 assert!(
1100 plan.checkpoint.state.committed_run("milo", "R").is_some(),
1101 "committed R survives compaction in the checkpoint oracle (C2 fixed)"
1102 );
1103 let reconstructed = fold_onto(&plan.checkpoint.state, &plan.retained_ops);
1104 assert_eq!(reconstructed, fold(&ops), "fold_onto(checkpoint, tail) == fold(full)");
1105 assert!(reconstructed.committed_run("milo", "R").is_some(), "oracle intact post-compaction");
1106 }
1107}