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 {
98 max_per_agent: usize,
99 max_age_ms: u64,
100 },
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct RetentionPolicy {
109 pub rules: BTreeMap<String, RetentionRule>,
110 pub default_rule: RetentionRule,
111}
112
113impl Default for RetentionPolicy {
114 fn default() -> Self {
115 Self::keep_all()
116 }
117}
118
119impl RetentionPolicy {
120 pub fn keep_all() -> Self {
123 Self {
124 rules: BTreeMap::new(),
125 default_rule: RetentionRule::KeepAll,
126 }
127 }
128
129 pub fn proposal_default(conversation_last_n: usize, trajectory_max_age_ms: u64) -> Self {
139 let mut rules = BTreeMap::new();
140 rules.insert(
141 "conversation".to_string(),
142 RetentionRule::LastN {
143 n: conversation_last_n,
144 },
145 );
146 rules.insert(
147 "run".to_string(),
148 RetentionRule::PerAgentWithAge {
149 max_per_agent: RUNS_MAX_PER_AGENT,
150 max_age_ms: RUNS_MAX_AGE_MS,
151 },
152 );
153 rules.insert(
154 "trajectory".to_string(),
155 RetentionRule::MaxAgeMs {
156 max_age_ms: trajectory_max_age_ms,
157 },
158 );
159 rules.insert("knowledge".to_string(), RetentionRule::KeepAll);
160 rules.insert("skill".to_string(), RetentionRule::KeepAll);
161 rules.insert("routing".to_string(), RetentionRule::KeepAll);
162 Self {
163 rules,
164 default_rule: RetentionRule::KeepAll,
165 }
166 }
167
168 pub fn rule_for(&self, surface_tag: &str) -> &RetentionRule {
169 self.rules.get(surface_tag).unwrap_or(&self.default_rule)
170 }
171}
172
173#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
177pub struct RetentionReport {
178 pub dropped: BTreeMap<String, usize>,
179 pub tombstoned: Vec<(String, String)>,
180}
181
182fn value_ts(payload: &Value) -> Option<u64> {
183 payload
184 .get("timestamp")
185 .and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f as u64)))
186}
187
188fn payload_ts(record: &FoldedRecord) -> Option<u64> {
189 value_ts(&record.payload)
190}
191
192pub fn as_of_from_ops(ops: &[OpRecord]) -> u64 {
200 ops.iter()
201 .filter_map(|op| value_ts(&op.payload))
202 .max()
203 .unwrap_or(0)
204}
205
206fn within_age(record: &FoldedRecord, as_of_ms: u64, max_age_ms: u64) -> bool {
207 match payload_ts(record) {
208 Some(ts) => as_of_ms.saturating_sub(ts) <= max_age_ms,
210 None => true,
212 }
213}
214
215fn recency_sorted(entries: &BTreeMap<String, FoldedRecord>) -> Vec<(&String, &FoldedRecord)> {
218 let mut sorted: Vec<(&String, &FoldedRecord)> = entries.iter().collect();
219 sorted.sort_by(|(_, a), (_, b)| {
220 (payload_ts(a).unwrap_or(u64::MAX), &a.hlc, &a.op_id).cmp(&(
221 payload_ts(b).unwrap_or(u64::MAX),
222 &b.hlc,
223 &b.op_id,
224 ))
225 });
226 sorted
227}
228
229fn select_retained(
230 entries: &BTreeMap<String, FoldedRecord>,
231 rule: &RetentionRule,
232 as_of_ms: u64,
233) -> BTreeSet<String> {
234 match rule {
235 RetentionRule::KeepAll => entries.keys().cloned().collect(),
236 RetentionRule::LastN { n } => recency_sorted(entries)
237 .into_iter()
238 .rev()
239 .take(*n)
240 .map(|(k, _)| k.clone())
241 .collect(),
242 RetentionRule::MaxAgeMs { max_age_ms } => entries
243 .iter()
244 .filter(|(_, r)| within_age(r, as_of_ms, *max_age_ms))
245 .map(|(k, _)| k.clone())
246 .collect(),
247 RetentionRule::PerAgentWithAge {
248 max_per_agent,
249 max_age_ms,
250 } => {
251 let mut per_agent_rank: BTreeMap<&str, usize> = BTreeMap::new();
263 let mut keep = BTreeSet::new();
264 for (key, record) in recency_sorted(entries).into_iter().rev() {
265 let agent = record
266 .payload
267 .get("agent_id")
268 .and_then(Value::as_str)
269 .unwrap_or("");
270 let rank = per_agent_rank.entry(agent).or_insert(0);
271 let over_count = *max_per_agent > 0 && *rank >= *max_per_agent;
272 *rank += 1;
273 let within_age = *max_age_ms == 0 || within_age(record, as_of_ms, *max_age_ms);
274 if !over_count && within_age {
275 keep.insert(key.clone());
276 }
277 }
278 keep
279 }
280 }
281}
282
283fn entity_id<'a>(key: &'a str, record: &'a FoldedRecord) -> Option<&'a str> {
286 key.strip_prefix("id:")
287 .or_else(|| record.payload.get("id").and_then(Value::as_str))
288}
289
290pub fn is_tombstone(record: &FoldedRecord) -> bool {
293 record
294 .payload
295 .get("tombstone")
296 .and_then(Value::as_bool)
297 .unwrap_or(false)
298}
299
300fn tombstone_of(record: &FoldedRecord, id: &str) -> FoldedRecord {
305 FoldedRecord {
306 op_id: record.op_id.clone(),
307 hlc: record.hlc.clone(),
308 payload: json!({"id": id, "tombstone": true}),
309 }
310}
311
312pub fn apply_retention(
323 state: &SyncState,
324 policy: &RetentionPolicy,
325 as_of_ms: u64,
326) -> Result<(SyncState, RetentionReport), CompactError> {
327 let routing_tag = crate::oplog::Surface::Routing.tag();
338 debug_assert!(crate::oplog::Surface::Routing.is_replay_stream());
339 if state.logs.contains_key(&routing_tag)
340 && policy.rule_for(&routing_tag) != &RetentionRule::KeepAll
341 {
342 return Err(CompactError::EventStreamRetention {
343 surface: routing_tag,
344 });
345 }
346
347 if policy.rule_for(&crate::oplog::Surface::Intent.tag()) != &RetentionRule::KeepAll {
357 return Err(CompactError::IntentRetention);
358 }
359
360 let mut retained = state.clone();
361 let mut report = RetentionReport::default();
362 for (tag, entries) in &state.logs {
363 let rule = policy.rule_for(tag);
364 if rule == &RetentionRule::KeepAll {
365 continue;
366 }
367 let live: BTreeMap<String, FoldedRecord> = entries
370 .iter()
371 .filter(|(_, record)| !is_tombstone(record))
372 .map(|(key, record)| (key.clone(), record.clone()))
373 .collect();
374 let keep = select_retained(&live, rule, as_of_ms);
375 let surface = retained.logs.get_mut(tag).expect("cloned from state");
376 for (key, record) in &live {
377 if keep.contains(key) {
378 continue;
379 }
380 match entity_id(key, record) {
381 Some(id) => {
382 surface.insert(key.clone(), tombstone_of(record, id));
383 report.tombstoned.push((tag.clone(), key.clone()));
384 }
385 None => {
386 surface.remove(key);
387 *report.dropped.entry(tag.clone()).or_insert(0) += 1;
388 }
389 }
390 }
391 }
392 report.tombstoned.sort();
393 Ok((retained, report))
394}
395
396#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
409pub struct AckTable {
410 acked: BTreeMap<String, Hlc>,
411}
412
413impl AckTable {
414 pub fn new() -> Self {
415 Self::default()
416 }
417
418 pub fn ack(&mut self, device_id: impl Into<String>, frontier: Hlc) -> bool {
422 let device_id = device_id.into();
423 match self.acked.get(&device_id) {
424 Some(current) if frontier <= *current => false,
425 _ => {
426 self.acked.insert(device_id, frontier);
427 true
428 }
429 }
430 }
431
432 pub fn get(&self, device_id: &str) -> Option<&Hlc> {
433 self.acked.get(device_id)
434 }
435
436 pub fn devices(&self) -> impl Iterator<Item = &str> {
437 self.acked.keys().map(String::as_str)
438 }
439
440 pub fn stable_frontier(&self) -> Option<&Hlc> {
445 self.acked.values().min()
446 }
447
448 pub fn save(&self, path: &Path) -> std::io::Result<()> {
452 if let Some(parent) = path.parent() {
453 if !parent.as_os_str().is_empty() {
454 fs::create_dir_all(parent)?;
455 }
456 }
457 let tmp_path = {
458 let mut s = path.as_os_str().to_owned();
459 s.push(".tmp");
460 PathBuf::from(s)
461 };
462 {
463 let mut tmp = File::create(&tmp_path)?;
464 tmp.write_all(
465 serde_json::to_string(self)
466 .map_err(std::io::Error::other)?
467 .as_bytes(),
468 )?;
469 tmp.sync_all()?;
470 }
471 fs::rename(&tmp_path, path)
472 }
473
474 pub fn load(path: &Path) -> std::io::Result<Self> {
478 match fs::read_to_string(path) {
479 Ok(raw) => serde_json::from_str(&raw).map_err(std::io::Error::other),
480 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
481 Err(e) => Err(e),
482 }
483 }
484}
485
486#[derive(Debug)]
489pub enum CompactError {
490 Chain(ChainError),
493 NothingAcked,
496 UnackedDevice {
499 device_id: String,
500 },
501 EventStreamRetention {
505 surface: String,
506 },
507 IntentRetention,
511 TruncatedJournal {
516 checkpoint_hash: String,
517 },
518 Io(std::io::Error),
519}
520
521impl fmt::Display for CompactError {
522 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
523 match self {
524 CompactError::Chain(e) => write!(f, "compaction refused: log does not verify: {e}"),
525 CompactError::NothingAcked => {
526 write!(
527 f,
528 "compaction refused: no acked frontier exists (empty ack table)"
529 )
530 }
531 CompactError::UnackedDevice { device_id } => write!(
532 f,
533 "compaction refused: device {device_id} appears in the log but has no acked \
534 frontier — its ops may include state no other replica has folded"
535 ),
536 CompactError::EventStreamRetention { surface } => write!(
537 f,
538 "retention policy for event-stream surface {surface} must be keep_all — \
539 observation multisets replay from genesis and cannot be trimmed"
540 ),
541 CompactError::IntentRetention => write!(
542 f,
543 "retention policy for the leased `intent` surface must be keep_all — the \
544 committed-run idempotency oracle must survive compaction and cannot be trimmed"
545 ),
546 CompactError::TruncatedJournal { checkpoint_hash } => write!(
547 f,
548 "compaction refused: journal already truncated below checkpoint \
549 {checkpoint_hash} — re-planning from the tail alone would drop that \
550 checkpoint's state (recompaction over a checkpoint base is a later slice)"
551 ),
552 CompactError::Io(e) => write!(f, "compaction io error: {e}"),
553 }
554 }
555}
556
557impl std::error::Error for CompactError {}
558
559#[derive(Debug)]
562pub struct CompactionPlan {
563 pub checkpoint: Checkpoint,
566 pub retained_ops: Vec<OpRecord>,
568 pub dropped_ops: usize,
571 pub frontier: Hlc,
573 pub as_of_ms: u64,
576 pub retention: RetentionReport,
578}
579
580pub fn plan_compaction(
591 ops: &[OpRecord],
592 acks: &AckTable,
593 policy: &RetentionPolicy,
594 as_of_ms: Option<u64>,
595) -> Result<CompactionPlan, CompactError> {
596 verify_log(ops).map_err(CompactError::Chain)?;
597 let frontier = acks
598 .stable_frontier()
599 .cloned()
600 .ok_or(CompactError::NothingAcked)?;
601 for op in ops {
602 if acks.get(&op.device_id).is_none() {
603 return Err(CompactError::UnackedDevice {
604 device_id: op.device_id.clone(),
605 });
606 }
607 }
608
609 let (below, retained_ops): (Vec<OpRecord>, Vec<OpRecord>) =
610 ops.iter().cloned().partition(|op| op.hlc <= frontier);
611 let as_of_ms = as_of_ms.unwrap_or_else(|| as_of_from_ops(&below));
612
613 let exact = Checkpoint::from_ops(&below).map_err(CompactError::Chain)?;
616 let (retained_state, retention) = apply_retention(&exact.state, policy, as_of_ms)?;
617 let checkpoint = Checkpoint::assemble(exact.frontier, exact.scopes, retained_state);
618
619 Ok(CompactionPlan {
620 checkpoint,
621 retained_ops,
622 dropped_ops: below.len(),
623 frontier,
624 as_of_ms,
625 retention,
626 })
627}
628
629#[derive(Debug)]
631pub struct CompactionOutcome {
632 pub checkpoint_path: Option<PathBuf>,
638 pub plan: CompactionPlan,
639}
640
641pub fn compact_and_truncate(
664 journal: &mut OplogJournal,
665 checkpoint_dir: &Path,
666 acks: &AckTable,
667 policy: &RetentionPolicy,
668 as_of_ms: Option<u64>,
669) -> Result<CompactionOutcome, CompactError> {
670 let (marker, ops) = OplogJournal::load_with_marker(journal.path()).map_err(CompactError::Io)?;
671 if let Some(marker) = marker {
672 return Err(CompactError::TruncatedJournal {
673 checkpoint_hash: marker.checkpoint_hash,
674 });
675 }
676 let plan = plan_compaction(&ops, acks, policy, as_of_ms)?;
677 if plan.dropped_ops == 0 {
678 return Ok(CompactionOutcome {
681 checkpoint_path: None,
682 plan,
683 });
684 }
685 let checkpoint_path = plan
687 .checkpoint
688 .save(checkpoint_dir)
689 .map_err(CompactError::Io)?;
690 journal
691 .truncate_to(&plan.retained_ops, &plan.checkpoint.checkpoint_hash)
692 .map_err(CompactError::Io)?;
693 Ok(CompactionOutcome {
694 checkpoint_path: Some(checkpoint_path),
695 plan,
696 })
697}
698
699#[cfg(test)]
700mod tests {
701 use super::*;
702 use crate::fold::fold;
703 use crate::oplog::{DeviceLog, Scope, Surface};
704 use serde_json::json;
705
706 fn hlc(wall_ms: u64, device: &str) -> Hlc {
707 Hlc {
708 wall_ms,
709 counter: 0,
710 device_id: device.into(),
711 }
712 }
713
714 #[test]
715 fn ack_table_is_monotone_only_and_min_frontier() {
716 let mut acks = AckTable::new();
717 assert_eq!(acks.stable_frontier(), None);
718 assert!(acks.ack("a", hlc(5, "a")));
719 assert!(acks.ack("b", hlc(9, "b")));
720 assert_eq!(
721 acks.stable_frontier(),
722 Some(&hlc(5, "a")),
723 "min over devices"
724 );
725
726 assert!(!acks.ack("b", hlc(3, "b")));
728 assert!(!acks.ack("b", hlc(9, "b")), "equal is not an advance");
729 assert_eq!(acks.get("b"), Some(&hlc(9, "b")));
730 assert!(acks.ack("b", hlc(12, "b")));
731 assert_eq!(acks.get("b"), Some(&hlc(12, "b")));
732 }
733
734 #[test]
735 fn ack_table_persists_atomically_and_loads_missing_as_empty() {
736 let dir = tempfile::tempdir().unwrap();
737 let path = dir.path().join("nested").join("acks.json");
738 assert_eq!(
739 AckTable::load(&path).unwrap(),
740 AckTable::new(),
741 "missing → empty"
742 );
743
744 let mut acks = AckTable::new();
745 acks.ack("a", hlc(5, "a"));
746 acks.ack("b", hlc(9, "b"));
747 acks.save(&path).unwrap();
748 assert_eq!(AckTable::load(&path).unwrap(), acks);
749 assert!(!path.parent().unwrap().join("acks.json.tmp").exists());
751 }
752
753 fn simple_ops() -> Vec<OpRecord> {
755 let mut a = DeviceLog::new("a");
756 let mut b = DeviceLog::new("b");
757 let mut ops = vec![
758 a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f1"})),
759 a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f2"})),
760 a.append(Scope::Personal, Surface::Knowledge, json!({"id": "f3"})),
761 ];
762 for op in &ops {
763 b.observe(&op.hlc);
764 }
765 ops.push(b.append(Scope::Personal, Surface::Knowledge, json!({"id": "f4"})));
766 ops
767 }
768
769 #[test]
770 fn compaction_refuses_without_acks() {
771 let ops = simple_ops();
772 let policy = RetentionPolicy::keep_all();
773 assert!(matches!(
774 plan_compaction(&ops, &AckTable::new(), &policy, None),
775 Err(CompactError::NothingAcked)
776 ));
777
778 let mut acks = AckTable::new();
781 acks.ack("a", ops[2].hlc.clone());
782 assert!(matches!(
783 plan_compaction(&ops, &acks, &policy, None),
784 Err(CompactError::UnackedDevice { .. })
785 ));
786 }
787
788 #[test]
789 fn compaction_never_drops_above_a_lagging_ack() {
790 let ops = simple_ops();
791 let mut acks = AckTable::new();
792 acks.ack("b", ops[3].hlc.clone());
794 acks.ack("a", ops[1].hlc.clone());
795
796 let plan = plan_compaction(&ops, &acks, &RetentionPolicy::keep_all(), None).unwrap();
797 assert_eq!(
798 plan.frontier, ops[1].hlc,
799 "stable frontier = the lagging device's ack"
800 );
801 assert_eq!(plan.dropped_ops, 2, "only ops ≤ the lagging frontier drop");
802 assert_eq!(plan.retained_ops.len(), 2);
803 assert!(
804 plan.retained_ops.iter().all(|op| op.hlc > plan.frontier),
805 "everything a device hasn't seen stays in the journal"
806 );
807 }
808
809 #[test]
810 fn retention_conversations_last_n_by_timestamp() {
811 let mut dev = DeviceLog::new("a");
812 let ops: Vec<OpRecord> = (0..5)
813 .map(|i| {
814 dev.append(
815 Scope::Personal,
816 Surface::Conversation,
817 json!({"speaker": "u", "text": format!("t{i}"), "timestamp": 100 + i}),
818 )
819 })
820 .collect();
821 let state = fold(&ops);
822 let policy = RetentionPolicy::proposal_default(2, u64::MAX);
823 let (retained, report) = apply_retention(&state, &policy, 1_000).unwrap();
824 let tag = Surface::Conversation.tag();
825 let texts: Vec<String> = retained
826 .log_entries(&tag)
827 .iter()
828 .map(|r| r.payload["text"].as_str().unwrap().to_string())
829 .collect();
830 assert_eq!(texts, vec!["t3", "t4"], "last 2 turns by timestamp survive");
831 assert_eq!(report.dropped[&tag], 3);
832 }
833
834 #[test]
843 fn a_zero_cap_here_disables_it_too_rather_than_dropping_everything() {
844 let mut dev = DeviceLog::new("a");
845 let mut ops = Vec::new();
846 for (id, agent, ts) in [
847 ("r1", "milo", 10u64),
848 ("r2", "milo", 60),
849 ("r3", "other", 10),
850 ] {
851 ops.push(dev.append(
852 Scope::Personal,
853 Surface::Run,
854 json!({"id": id, "agent_id": agent, "timestamp": ts}),
855 ));
856 }
857 let state = fold(&ops);
858
859 for rule in [
860 RetentionRule::PerAgentWithAge {
862 max_per_agent: 0,
863 max_age_ms: u64::MAX,
864 },
865 RetentionRule::PerAgentWithAge {
867 max_per_agent: 100,
868 max_age_ms: 0,
869 },
870 RetentionRule::PerAgentWithAge {
871 max_per_agent: 0,
872 max_age_ms: 0,
873 },
874 ] {
875 let mut policy = RetentionPolicy::keep_all();
876 policy.rules.insert("run".to_string(), rule.clone());
877 let (retained, report) = apply_retention(&state, &policy, 1_000_000).unwrap();
878 let entries = retained.log_entries(&Surface::Run.tag());
879 let kept: Vec<&str> = entries
880 .iter()
881 .filter(|r| !is_tombstone(r))
882 .map(|r| r.payload["id"].as_str().unwrap())
883 .collect();
884 assert_eq!(
885 kept,
886 vec!["r1", "r2", "r3"],
887 "{rule:?} must keep everything"
888 );
889 assert!(report.tombstoned.is_empty(), "{rule:?}");
890 }
891 }
892
893 #[test]
894 fn retention_runs_per_agent_and_age_matches_run_store_gc() {
895 assert_eq!(
896 RUNS_MAX_PER_AGENT, 50,
897 "parity with run_store DEFAULT_MAX_RUNS_PER_AGENT"
898 );
899 assert_eq!(
900 RUNS_MAX_AGE_MS,
901 30 * 24 * 60 * 60 * 1000,
902 "parity with DEFAULT_MAX_AGE_DAYS"
903 );
904
905 let mut dev = DeviceLog::new("a");
908 let mut ops = Vec::new();
909 for (id, agent, ts) in [
910 ("r1", "milo", 10u64), ("r2", "milo", 60), ("r3", "milo", 70), ("r4", "other", 10), ("r5", "other", 80), ] {
916 ops.push(dev.append(
917 Scope::Personal,
918 Surface::Run,
919 json!({"id": id, "agent_id": agent, "timestamp": ts}),
920 ));
921 }
922 let state = fold(&ops);
923 let mut policy = RetentionPolicy::keep_all();
924 policy.rules.insert(
925 "run".to_string(),
926 RetentionRule::PerAgentWithAge {
927 max_per_agent: 2,
928 max_age_ms: 50,
929 },
930 );
931 let (retained, report) = apply_retention(&state, &policy, 100).unwrap();
932 let entries = retained.log_entries(&Surface::Run.tag());
933 let kept: Vec<&str> = entries
934 .iter()
935 .filter(|r| !is_tombstone(r))
936 .map(|r| r.payload["id"].as_str().unwrap())
937 .collect();
938 assert_eq!(kept, vec!["r2", "r3", "r5"]);
939 let stubs: Vec<&str> = entries
941 .iter()
942 .filter(|r| is_tombstone(r))
943 .map(|r| r.payload["id"].as_str().unwrap())
944 .collect();
945 assert_eq!(stubs, vec!["r1", "r4"]);
946 assert_eq!(report.tombstoned.len(), 2);
947 assert!(
948 report.dropped.is_empty(),
949 "id-bearing entries are stubbed, never erased"
950 );
951 }
952
953 #[test]
954 fn retention_trajectories_by_age_and_undated_never_dropped() {
955 let mut dev = DeviceLog::new("a");
956 let ops = vec![
957 dev.append(
958 Scope::Personal,
959 Surface::Trajectory,
960 json!({"id": "old", "timestamp": 10}),
961 ),
962 dev.append(
963 Scope::Personal,
964 Surface::Trajectory,
965 json!({"id": "new", "timestamp": 90}),
966 ),
967 dev.append(
968 Scope::Personal,
969 Surface::Trajectory,
970 json!({"id": "undated"}),
971 ),
972 ];
973 let state = fold(&ops);
974 let mut policy = RetentionPolicy::keep_all();
975 policy.rules.insert(
976 "trajectory".to_string(),
977 RetentionRule::MaxAgeMs { max_age_ms: 30 },
978 );
979 let (retained, _) = apply_retention(&state, &policy, 100).unwrap();
980 let entries = retained.log_entries(&Surface::Trajectory.tag());
981 let kept: Vec<&str> = entries
982 .iter()
983 .filter(|r| !is_tombstone(r))
984 .map(|r| r.payload["id"].as_str().unwrap())
985 .collect();
986 assert!(kept.contains(&"new"));
987 assert!(
988 kept.contains(&"undated"),
989 "undated data is never silently age-dropped"
990 );
991 assert!(!kept.contains(&"old"));
992 assert!(entries
994 .iter()
995 .any(|r| is_tombstone(r) && r.payload["id"] == json!("old")));
996 }
997
998 #[test]
999 fn knowledge_and_skills_keep_all_under_the_proposal_default() {
1000 let mut dev = DeviceLog::new("a");
1001 let ops = vec![
1002 dev.append(
1003 Scope::Personal,
1004 Surface::Knowledge,
1005 json!({"id": "f1", "timestamp": 1}),
1006 ),
1007 dev.append(
1008 Scope::Personal,
1009 Surface::Skill,
1010 json!({"id": "s1", "timestamp": 1}),
1011 ),
1012 ];
1013 let state = fold(&ops);
1014 let (retained, report) =
1016 apply_retention(&state, &RetentionPolicy::proposal_default(1, 1), u64::MAX).unwrap();
1017 assert_eq!(retained.logs[&Surface::Knowledge.tag()].len(), 1);
1018 assert_eq!(retained.logs[&Surface::Skill.tag()].len(), 1);
1019 assert!(report.dropped.is_empty());
1020 }
1021
1022 #[test]
1023 fn event_stream_retention_is_rejected() {
1024 let mut dev = DeviceLog::new("a");
1025 let ops = vec![
1026 dev.append(Scope::Personal, Surface::Routing, json!({"sample": 1.0})),
1027 dev.append(Scope::Personal, Surface::Routing, json!({"sample": 0.0})),
1028 ];
1029 let state = fold(&ops);
1030 let mut policy = RetentionPolicy::keep_all();
1031 policy
1032 .rules
1033 .insert("routing".to_string(), RetentionRule::LastN { n: 1 });
1034 assert!(matches!(
1035 apply_retention(&state, &policy, 0),
1036 Err(CompactError::EventStreamRetention { .. })
1037 ));
1038 let (retained, _) =
1040 apply_retention(&state, &RetentionPolicy::proposal_default(10, 10), u64::MAX).unwrap();
1041 assert_eq!(retained.log_entries(&Surface::Routing.tag()).len(), 2);
1042 }
1043
1044 #[test]
1045 fn every_dropped_id_bearing_entry_leaves_a_tombstone_stub() {
1046 let mut dev = DeviceLog::new("a");
1051 let ops = vec![
1052 dev.append(
1053 Scope::Personal,
1054 Surface::Knowledge,
1055 json!({"id": "f1", "timestamp": 1}),
1056 ),
1057 dev.append(
1058 Scope::Personal,
1059 Surface::Knowledge,
1060 json!({"id": "f2", "timestamp": 2, "supersedes": "f1"}),
1061 ),
1062 dev.append(
1063 Scope::Personal,
1064 Surface::Knowledge,
1065 json!({"id": "f3", "timestamp": 3, "supersedes": ["f2"]}),
1066 ),
1067 dev.append(
1068 Scope::Personal,
1069 Surface::Knowledge,
1070 json!({"id": "f0", "timestamp": 0}),
1071 ),
1072 ];
1073 let state = fold(&ops);
1074 let mut policy = RetentionPolicy::keep_all();
1075 policy
1076 .rules
1077 .insert("knowledge".to_string(), RetentionRule::LastN { n: 1 });
1078 let (retained, report) = apply_retention(&state, &policy, 10).unwrap();
1079 let tag = Surface::Knowledge.tag();
1080 let surface = &retained.logs[&tag];
1081 assert_eq!(
1082 surface["id:f3"].payload["timestamp"],
1083 json!(3),
1084 "newest stays live"
1085 );
1086 for id in ["f0", "f1", "f2"] {
1087 let stub = &surface[&format!("id:{id}")];
1088 assert!(is_tombstone(stub), "{id} left a stub");
1089 assert_eq!(
1090 stub.payload,
1091 json!({"id": id, "tombstone": true}),
1092 "minimal stub shape"
1093 );
1094 assert_eq!(
1095 stub.op_id,
1096 state.logs[&tag][&format!("id:{id}")].op_id,
1097 "stub keeps the original op identity"
1098 );
1099 }
1100 assert_eq!(report.tombstoned.len(), 3);
1101 assert!(report.dropped.is_empty());
1102
1103 let (again, report2) = apply_retention(&retained, &policy, 10).unwrap();
1106 assert_eq!(again, retained);
1107 assert!(report2.tombstoned.is_empty());
1108 }
1109
1110 #[test]
1111 fn derived_as_of_is_the_max_below_frontier_timestamp() {
1112 let mut dev = DeviceLog::new("a");
1113 let ops = vec![
1114 dev.append(
1115 Scope::Personal,
1116 Surface::Trajectory,
1117 json!({"id": "t1", "timestamp": 40}),
1118 ),
1119 dev.append(
1120 Scope::Personal,
1121 Surface::Trajectory,
1122 json!({"id": "t2", "timestamp": 100}),
1123 ),
1124 dev.append(Scope::Personal, Surface::Skill, json!({"id": "s1"})), ];
1126 assert_eq!(as_of_from_ops(&ops), 100, "max payload timestamp");
1127 assert_eq!(
1128 as_of_from_ops(&ops[2..]),
1129 0,
1130 "no timestamps → 0 (age rules drop nothing)"
1131 );
1132
1133 let mut acks = AckTable::new();
1137 acks.ack("a", ops[2].hlc.clone());
1138 let mut policy = RetentionPolicy::keep_all();
1139 policy.rules.insert(
1140 "trajectory".to_string(),
1141 RetentionRule::MaxAgeMs { max_age_ms: 30 },
1142 );
1143 let plan = plan_compaction(&ops, &acks, &policy, None).unwrap();
1144 assert_eq!(plan.as_of_ms, 100);
1145 let surface = &plan.checkpoint.state.logs[&Surface::Trajectory.tag()];
1147 assert!(is_tombstone(&surface["id:t1"]));
1148 assert!(!is_tombstone(&surface["id:t2"]));
1149 }
1150
1151 #[test]
1152 fn empty_below_frontier_compaction_is_a_no_op() {
1153 let dir = tempfile::tempdir().unwrap();
1154 let journal_path = dir.path().join("oplog.jsonl");
1155 let ckpt_dir = dir.path().join("checkpoints");
1156
1157 let ops = simple_ops();
1160 let mut journal = OplogJournal::open(&journal_path).unwrap();
1161 for op in &ops {
1162 journal.append(op).unwrap();
1163 }
1164 let mut acks = AckTable::new();
1165 acks.ack(
1166 "a",
1167 Hlc {
1168 wall_ms: 0,
1169 counter: 0,
1170 device_id: "a".into(),
1171 },
1172 );
1173 acks.ack(
1174 "b",
1175 Hlc {
1176 wall_ms: 0,
1177 counter: 0,
1178 device_id: "b".into(),
1179 },
1180 );
1181
1182 let before = fs::read_to_string(&journal_path).unwrap();
1183 let outcome = compact_and_truncate(
1184 &mut journal,
1185 &ckpt_dir,
1186 &acks,
1187 &RetentionPolicy::keep_all(),
1188 None,
1189 )
1190 .unwrap();
1191 assert_eq!(outcome.plan.dropped_ops, 0);
1192 assert!(
1193 outcome.checkpoint_path.is_none(),
1194 "no empty checkpoint file written"
1195 );
1196 assert!(!ckpt_dir.exists(), "checkpoint dir not even created");
1197 assert_eq!(
1198 fs::read_to_string(&journal_path).unwrap(),
1199 before,
1200 "journal untouched (no marker, no rewrite)"
1201 );
1202 assert_eq!(OplogJournal::load(&journal_path).unwrap(), ops);
1204 }
1205
1206 #[test]
1207 fn recompacting_an_already_truncated_journal_is_refused() {
1208 let dir = tempfile::tempdir().unwrap();
1209 let journal_path = dir.path().join("oplog.jsonl");
1210 let ckpt_dir = dir.path().join("checkpoints");
1211 let ops = simple_ops();
1212 let mut journal = OplogJournal::open(&journal_path).unwrap();
1213 for op in &ops {
1214 journal.append(op).unwrap();
1215 }
1216 let mut acks = AckTable::new();
1217 acks.ack("a", ops[1].hlc.clone());
1218 acks.ack("b", ops[1].hlc.clone());
1219 let outcome = compact_and_truncate(
1220 &mut journal,
1221 &ckpt_dir,
1222 &acks,
1223 &RetentionPolicy::keep_all(),
1224 None,
1225 )
1226 .unwrap();
1227 let expected_hash = outcome.plan.checkpoint.checkpoint_hash.clone();
1228
1229 acks.ack("a", ops[3].hlc.clone());
1233 acks.ack("b", ops[3].hlc.clone());
1234 match compact_and_truncate(
1235 &mut journal,
1236 &ckpt_dir,
1237 &acks,
1238 &RetentionPolicy::keep_all(),
1239 None,
1240 ) {
1241 Err(CompactError::TruncatedJournal { checkpoint_hash }) => {
1242 assert_eq!(checkpoint_hash, expected_hash)
1243 }
1244 other => panic!("expected TruncatedJournal refusal, got {other:?}"),
1245 }
1246 }
1247
1248 #[test]
1249 fn plan_refuses_an_invalid_log() {
1250 let mut ops = simple_ops();
1251 ops[1].payload = json!({"forged": true});
1252 let mut acks = AckTable::new();
1253 acks.ack("a", ops[2].hlc.clone());
1254 acks.ack("b", ops[3].hlc.clone());
1255 assert!(matches!(
1256 plan_compaction(&ops, &acks, &RetentionPolicy::keep_all(), None),
1257 Err(CompactError::Chain(ChainError::IdMismatch { .. }))
1258 ));
1259 }
1260
1261 #[test]
1262 fn intent_retention_rule_is_rejected() {
1263 use crate::lease::{Intent, IntentStatus};
1267 let mut dev = DeviceLog::new("a");
1268 let ops = vec![dev.append(
1269 Scope::Personal,
1270 Surface::Intent,
1271 Intent::new("milo", "R", 1, IntentStatus::Committed).payload(),
1272 )];
1273 let state = fold(&ops);
1274 let mut policy = RetentionPolicy::keep_all();
1275 policy
1276 .rules
1277 .insert("intent".to_string(), RetentionRule::LastN { n: 1 });
1278 assert!(matches!(
1279 apply_retention(&state, &policy, 0),
1280 Err(CompactError::IntentRetention)
1281 ));
1282 let (retained, _) = apply_retention(&state, &RetentionPolicy::keep_all(), 0).unwrap();
1284 assert!(retained.committed_run("milo", "R").is_some());
1285 }
1286
1287 #[test]
1288 fn c2_committed_run_survives_a_checkpoint_compaction() {
1289 use crate::fold::fold_onto;
1294 use crate::lease::{Intent, IntentStatus};
1295
1296 let mut a = DeviceLog::new("a");
1297 let mut b = DeviceLog::new("b");
1298 let mut ops = vec![a.append(
1299 Scope::Personal,
1300 Surface::Intent,
1301 Intent::new("milo", "R", 1, IntentStatus::Committed).payload(),
1302 )];
1303 let split = ops.len();
1304 for op in &ops {
1305 b.observe(&op.hlc);
1306 }
1307 ops.push(b.append(
1309 Scope::Personal,
1310 Surface::Intent,
1311 Intent::new("milo", "S", 2, IntentStatus::Pending).payload(),
1312 ));
1313
1314 let frontier = ops[..split].iter().map(|o| o.hlc.clone()).max().unwrap();
1316 let mut acks = AckTable::new();
1317 acks.ack("a", frontier.clone());
1318 acks.ack("b", frontier);
1319 let plan = plan_compaction(&ops, &acks, &RetentionPolicy::keep_all(), None).unwrap();
1320 assert_eq!(
1321 plan.dropped_ops, split,
1322 "committed R is below the frontier, dropped from journal"
1323 );
1324
1325 assert!(
1328 plan.checkpoint.state.committed_run("milo", "R").is_some(),
1329 "committed R survives compaction in the checkpoint oracle (C2 fixed)"
1330 );
1331 let reconstructed = fold_onto(&plan.checkpoint.state, &plan.retained_ops);
1332 assert_eq!(
1333 reconstructed,
1334 fold(&ops),
1335 "fold_onto(checkpoint, tail) == fold(full)"
1336 );
1337 assert!(
1338 reconstructed.committed_run("milo", "R").is_some(),
1339 "oracle intact post-compaction"
1340 );
1341 }
1342}