Skip to main content

car_sync/
compact.rs

1//! Compaction + oplog GC (slice B4 of `docs/proposals/multi-device-sync.md`,
2//! §"Deep dive: the checkpoint / compaction / GC protocol").
3//!
4//! Compaction = **checkpoint at the stable frontier, then drop the ops below
5//! it**. Three guards make that safe, each straight from the proposal:
6//!
7//! 1. **The frontier is the min acked HLC over every known device**
8//!    ([`AckTable::stable_frontier`]) — "GC of a CRDT log is only safe once
9//!    every replica has folded past the truncation point". Compaction
10//!    refuses to run at all when a device present in the log has no ack
11//!    entry ([`CompactError::UnackedDevice`]) or when nothing is acked
12//!    ([`CompactError::NothingAcked`]): ops above ANY device's acked
13//!    frontier are data another device hasn't seen, and are never dropped.
14//!    (Device eviction — the horizon `H` that unpins a dead laptop — is
15//!    relay policy, B3.)
16//! 2. **Checkpoint durable FIRST, then truncate**
17//!    ([`compact_and_truncate`]). The crash-ordering invariant: a crash
18//!    after the checkpoint fsync but before the journal rename leaves the
19//!    full journal plus a redundant checkpoint (harmless — rerunning
20//!    compaction recomputes the identical content-addressed file); the
21//!    rename itself is atomic, so mid-truncation crashes leave either the
22//!    old complete journal or the new complete tail. **At no point does
23//!    acknowledged data exist only in a file that isn't durably written.**
24//! 3. **Retention is applied to the checkpoint state, never to the tail.**
25//!    The proposal's per-surface retention table
26//!    ([`RetentionPolicy::proposal_default`]) trims what the snapshot
27//!    *keeps*; ops above the frontier are untouched by policy. **Replay**
28//!    surfaces (routing observations — path-dependent, replayed from genesis;
29//!    [`crate::oplog::Surface::is_replay_stream`]) additionally reject any rule
30//!    but keep-all ([`CompactError::EventStreamRetention`]), so an unacked (or
31//!    acked!) observation tail can never be compacted away. (Conversation is
32//!    an event-stream multiset too — B2 — but its turns are independent, so it
33//!    tolerates `LastN`; only replay streams are forbidden.) **Every
34//!    retention-dropped id-bearing entry leaves a minimal tombstone stub**
35//!    (`{"id": …, "tombstone": true}`, original `op_id`/`hlc` kept), so a
36//!    `"supersedes"` reference always resolves against a tombstone, not a
37//!    hole — **including a reference that arrives AFTER compaction**.
38//!    (Preserving only the references visible at compaction time was a
39//!    reproduced divergence: a later tail op superseding an
40//!    already-dropped entry made the global fold retain what the compacted
41//!    device could not. Universal stubs are time-hole-free: both sides
42//!    reduce the same record to the same stub, deterministically. Entries
43//!    with no entity id — e.g. content-hash-keyed conversation turns —
44//!    cannot be referenced by id and drop entirely.) Stubs are carried
45//!    unchanged by later retention passes and never count against a
46//!    surface's retention quota.
47//!
48//! Determinism note (the "same frontier ⇒ same snapshot hash" invariant):
49//! retention is deterministic over a fixed state, and the age-based rules'
50//! reference instant defaults to [`as_of_from_ops`] — the max payload
51//! `"timestamp"` among the ops at/below the frontier, i.e. **pure over the
52//! same inputs the fold already consumes**, so every device compacting the
53//! same frontier derives the same instant with no out-of-band agreement
54//! (this crate never reads a clock; a caller may still pass an explicit
55//! `Some(as_of_ms)`). The derived value is conservative: a stale max
56//! under-drops, and undated entries never age-drop anyway.
57//! Ordering/recency come from the payload's numeric `"timestamp"` field
58//! (ms) and grouping from `"agent_id"`; an entry with no timestamp is
59//! treated as newest / never age-dropped — undated data is never silently
60//! discarded.
61
62use 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
74/// Parity constants with today's run GC (`car-server-core::run_store`):
75/// keep the 50 most recent runs per agent…
76pub const RUNS_MAX_PER_AGENT: usize = 50;
77/// …and drop runs older than 30 days (in ms), whichever is more
78/// restrictive — exactly `RunStore::gc`'s rule, made globally coherent.
79pub const RUNS_MAX_AGE_MS: u64 = 30 * 24 * 60 * 60 * 1000;
80
81/// How one surface's checkpoint retention trims — the proposal's table rows.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum RetentionRule {
85    /// Keep everything (knowledge/skills — "the valuable distilled state" —
86    /// and forced for event-stream surfaces).
87    KeepAll,
88    /// Keep the last `n` entries by payload `"timestamp"` (conversations —
89    /// "last N turns by timestamp (= today's `max_turns`)").
90    LastN { n: usize },
91    /// Keep entries no older than `max_age_ms` relative to the compaction's
92    /// `as_of_ms` (trajectories — "last D days").
93    MaxAgeMs { max_age_ms: u64 },
94    /// Keep the most recent `max_per_agent` per payload `"agent_id"` AND
95    /// drop anything older than `max_age_ms` — both restrictive, the
96    /// `RunStore::gc` rule (runs — "50 / agent + 30 days").
97    PerAgentWithAge {
98        max_per_agent: usize,
99        max_age_ms: u64,
100    },
101}
102
103/// Per-surface retention policy: surface tag → rule, with a conservative
104/// keep-all default for unknown surfaces. Applies to the grow-only log tier
105/// only — the LWW registry tier is already one compact record per id, and
106/// the proposal's table assigns it no retention.
107#[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    /// Retain everything — the exact-checkpoint policy (compaction still
121    /// drops ops; the snapshot just keeps their full folded state).
122    pub fn keep_all() -> Self {
123        Self {
124            rules: BTreeMap::new(),
125            default_rule: RetentionRule::KeepAll,
126        }
127    }
128
129    /// The proposal's retention table. Two rows are deployment-configurable
130    /// in the proposal itself and therefore parameters here: conversations'
131    /// `N` ("= today's `max_turns`" — a memgine config value, not a global
132    /// constant) and trajectories' `D` days (given as `max_age_ms`). Runs
133    /// use the shipped `RunStore::gc` constants
134    /// ([`RUNS_MAX_PER_AGENT`]/[`RUNS_MAX_AGE_MS`]) and its semantics, in which
135    /// a zero cap is DISABLED rather than zero-tolerance (car#1338); knowledge, skills, and
136    /// routing observations keep all (routing's keep-all is also enforced
137    /// structurally — see [`CompactError::EventStreamRetention`]).
138    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/// What retention did: per-surface counts of entries dropped entirely
174/// (id-less — nothing can reference them) and the entries reduced to
175/// tombstone stubs, as `(surface_tag, key)`.
176#[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
192/// The deterministic default reference instant for age-based retention:
193/// the max payload `"timestamp"` across `ops` (0 when none carry one).
194/// Pure over the same inputs the fold consumes, so every device compacting
195/// the same frontier derives the identical value — no out-of-band
196/// agreement, no clock read. Conservative by construction: a stale max
197/// under-drops (age rules see everything as newer), and undated entries
198/// never age-drop regardless.
199pub 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        // Future-stamped entries have age 0 (saturating) — kept.
209        Some(ts) => as_of_ms.saturating_sub(ts) <= max_age_ms,
210        // Undated data is never age-dropped.
211        None => true,
212    }
213}
214
215/// Deterministic recency order: ascending `(timestamp, hlc, op_id)`, with a
216/// missing timestamp sorting as newest (never preferentially dropped).
217fn 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            // Rank per agent over ALL entries (recency), then apply both
252            // caps restrictively — RunStore::gc's exact semantics, INCLUDING
253            // that a zero cap is disabled rather than zero-tolerance
254            // (car#1338). Read literally, `max_per_agent = 0` makes
255            // `rank >= 0` true for every entry and drops the lot, which is the
256            // reading `RunStore::gc` carried until it cost someone their run
257            // store. `proposal_default` only ever passes the shipped constants
258            // today, so this is latent — but `RetentionPolicy` is
259            // `Serialize`/`Deserialize`, so the day a rule arrives from config
260            // or the wire it stops being latent, and the parity test next door
261            // compares only the default constants.
262            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
283/// The entity id a folded log entry answers to for tombstone stubs: the
284/// `id:` key form, else the payload's own `"id"`.
285fn 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
290/// Is this record a retention tombstone stub? (`"tombstone": true` is the
291/// reserved marker [`apply_retention`] stamps.)
292pub 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
300/// The minimal stub a retention-dropped entry leaves behind: the entity id
301/// plus the tombstone marker, under the record's original `op_id`/`hlc`.
302/// Deterministic from the dropped record, so every device reduces it to the
303/// identical stub. Stubbing is idempotent (a stub of a stub is itself).
304fn 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
312/// Apply per-surface retention to a folded (checkpoint) state — the
313/// proposal's "the snapshot applies each surface's retention", with the
314/// §"Two free properties fall out" referential-integrity rule realized as
315/// **universal tombstone stubs**: every dropped id-bearing entry is reduced
316/// to `{"id", "tombstone": true}` (original `op_id`/`hlc` kept) rather than
317/// erased, so a `"supersedes"` reference — even one that arrives after
318/// compaction — always resolves. Stubs never count against a rule's quota
319/// and are carried unchanged by later passes. Pure over its inputs;
320/// `as_of_ms` is the reference instant for the age rules (no clock reads
321/// here — [`plan_compaction`] defaults it via [`as_of_from_ops`]).
322pub fn apply_retention(
323    state: &SyncState,
324    policy: &RetentionPolicy,
325    as_of_ms: u64,
326) -> Result<(SyncState, RetentionReport), CompactError> {
327    // Path-dependent REPLAY surfaces (routing's EMA) are retention-forbidden:
328    // the folded result is recomputed from the ordered multiset, so any trim
329    // silently corrupts every device's replay. Reject loudly.
330    //
331    // This is NARROWER than "event stream": conversation turns are an
332    // op_id-keyed multiset too (B2 — so two genuine same-content turns never
333    // collapse), but they are INDEPENDENT entries, so `LastN` over them is
334    // well-defined and MUST be allowed (conversations need last-N retention).
335    // Only replay streams are rejected here — see `Surface::is_replay_stream`.
336    // Routing is the only one today; its tag is the guard key.
337    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    // Leased execution intents (B5) are KEEP-ALL, structurally protected.
348    // `state.intents` (incl. the fence-independent `committed_runs` idempotency
349    // oracle) is carried untouched below — the fold routes `Surface::Intent`
350    // into `state.intents`, never `state.logs`, so retention here cannot reach
351    // it. A retention rule keyed on "intent" would therefore be a silent no-op
352    // that misleads a reader into thinking committed records get trimmed; reject
353    // it loudly so the keep-all invariant is explicit and reviewable, mirroring
354    // the event-stream guard. (The idempotency oracle surviving compaction
355    // depends on this: a committed run's record must NEVER be trimmable.)
356    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        // Selection ranks LIVE entries only: existing stubs are carried
368        // unchanged and never displace a live entry from the quota.
369        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/// The fold-frontier / ack bookkeeping B3's relay `ack(frontier)` reports
397/// against: per-device max folded HLC, **monotone-only** advance, persisted
398/// alongside the checkpoint (temp + atomic rename, like everything durable
399/// here). This is the proposal's `acked[device]` table, device-local.
400///
401/// **MUST, binding on B3: an ack asserts durably-folded state.** A device
402/// may report `ack(frontier)` only after the ops at/below that frontier are
403/// durably persisted on it (journal-durable, fold applied) — an ack sent
404/// from memory ahead of the fsync lets compaction drop ops the acking
405/// device then loses in a crash, which is exactly the data loss the stable
406/// frontier exists to prevent. The mirror of B1's
407/// journal-durable-before-transmit rule.
408#[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    /// Advance a device's acked frontier. Monotone-only: an ack at or below
419    /// the current frontier is ignored (returns `false`) — a delayed or
420    /// replayed ack can never move GC eligibility backwards.
421    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    /// The stable frontier — `min(acked[d])` over every known device
441    /// (proposal §"Snapshots"). Ops at or below it are folded by everyone
442    /// and are the only truncation candidates. `None` when no device has
443    /// acked (nothing is ever droppable then).
444    pub fn stable_frontier(&self) -> Option<&Hlc> {
445        self.acked.values().min()
446    }
447
448    /// Durably persist (temp + atomic rename); pairs with
449    /// [`AckTable::load`]. Kept alongside the checkpoint directory by
450    /// convention.
451    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    /// Load a persisted table; a missing file is an empty table (a fresh
475    /// device knows of no acks — and an empty table makes compaction
476    /// refuse, the safe default).
477    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/// A compaction failure. No `PartialEq` (carries `io::Error`); match on
487/// variants.
488#[derive(Debug)]
489pub enum CompactError {
490    /// The input log doesn't verify — never compact what you can't trust
491    /// (the B1 verify-before-fold contract).
492    Chain(ChainError),
493    /// No device has acked anything: no stable frontier exists, nothing is
494    /// provably folded-by-everyone, nothing may be dropped.
495    NothingAcked,
496    /// A device present in the log has no ack entry — its fold frontier is
497    /// unknown, so every op is data it may not have seen. Refuse.
498    UnackedDevice {
499        device_id: String,
500    },
501    /// The policy tried to trim an event-stream surface (op_id-keyed
502    /// observation multiset). Those replay from genesis; only keep-all is
503    /// sound.
504    EventStreamRetention {
505        surface: String,
506    },
507    /// The policy set a non-keep-all rule for the leased `intent` surface
508    /// (B5). Intents are keep-all — the `committed_runs` idempotency oracle
509    /// must survive compaction, so trimming it is never sound.
510    IntentRetention,
511    /// The journal was already truncated below a checkpoint (its truncation
512    /// marker names it). Re-planning from the tail alone would fold a
513    /// checkpoint that silently misses everything the prior checkpoint
514    /// covers — recompaction over a checkpoint base is a later slice.
515    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/// A computed (not yet executed) compaction: the retained checkpoint, the
560/// ops that stay in the journal, and what happened.
561#[derive(Debug)]
562pub struct CompactionPlan {
563    /// The checkpoint to persist BEFORE truncating — frontier heads +
564    /// retention-applied state, content-addressed over the whole record.
565    pub checkpoint: Checkpoint,
566    /// Ops strictly above the stable frontier — the journal's new content.
567    pub retained_ops: Vec<OpRecord>,
568    /// How many ops fall at/below the frontier (dropped from the journal,
569    /// covered by the checkpoint).
570    pub dropped_ops: usize,
571    /// The stable frontier the plan cut at.
572    pub frontier: Hlc,
573    /// The effective reference instant the age rules ran with (the caller's
574    /// explicit value, or [`as_of_from_ops`] over the below-frontier ops).
575    pub as_of_ms: u64,
576    /// What retention trimmed inside the checkpoint state.
577    pub retention: RetentionReport,
578}
579
580/// Plan a compaction of `ops` at the [`AckTable`]'s stable frontier. Pure —
581/// no file IO; [`compact_and_truncate`] executes a plan durably. See the
582/// module docs for the three safety guards; per-device HLC monotonicity
583/// (enforced by `verify_log`) guarantees "hlc ≤ frontier" is a chain prefix,
584/// so the cut is always anchorable.
585///
586/// `as_of_ms`: the age rules' reference instant. `None` (the default every
587/// caller should want) derives it deterministically via [`as_of_from_ops`]
588/// over the below-frontier ops, so identical frontiers yield identical
589/// retained checkpoints on every device.
590pub 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    // Exact fold at the frontier first (from_ops re-verifies the prefix),
614    // then retention on the snapshot only — never on the tail.
615    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/// The outcome of an executed compaction.
630#[derive(Debug)]
631pub struct CompactionOutcome {
632    /// Where the checkpoint landed (content-addressed file in
633    /// `checkpoint_dir`) — `None` when the plan dropped nothing (nothing at
634    /// or below the frontier), in which case neither a checkpoint write nor
635    /// a truncation happened: an empty compaction is a no-op, not an empty
636    /// checkpoint file.
637    pub checkpoint_path: Option<PathBuf>,
638    pub plan: CompactionPlan,
639}
640
641/// Execute a compaction end-to-end on a live journal, enforcing the
642/// crash-ordering invariant by construction:
643///
644/// 1. load + verify the journal (a journal already carrying a truncation
645///    marker is refused — [`CompactError::TruncatedJournal`] — because
646///    re-planning from the tail alone would silently lose the prior
647///    checkpoint's state);
648/// 2. plan at the ack table's stable frontier (a plan that drops nothing
649///    is a **no-op**: no checkpoint written, journal untouched);
650/// 3. **checkpoint durable FIRST** ([`Checkpoint::save`]: temp + fsync +
651///    atomic rename);
652/// 4. only then truncate the journal to the retained tail
653///    ([`OplogJournal::truncate_to`]: temp + atomic rename under the
654///    journal's advisory lock, stamping the truncation marker that fences
655///    `DeviceLog::resume`).
656///
657/// A crash between 3 and 4 leaves the full journal plus a redundant
658/// checkpoint; rerunning is idempotent (same frontier ⇒ same
659/// content-addressed checkpoint file, rename-over-identical). A crash
660/// inside 4 leaves either the old or the new journal, whole. Acknowledged
661/// data is never lost. (Steps are inherently sequential — each depends on
662/// the previous one's durability.)
663pub 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        // Nothing at or below the frontier: writing an empty checkpoint and
679        // rewriting the journal to itself would be pure churn. No-op.
680        return Ok(CompactionOutcome {
681            checkpoint_path: None,
682            plan,
683        });
684    }
685    // INVARIANT: checkpoint durable BEFORE any op leaves the journal.
686    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        // Regression is ignored — a replayed/late ack can't move GC back.
727        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        // No temp file left behind.
750        assert!(!path.parent().unwrap().join("acks.json.tmp").exists());
751    }
752
753    /// Ops: device a emits 3 knowledge facts, b (having observed) emits 1.
754    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        // A device in the log with no ack entry → refuse: its fold frontier
779        // is unknown.
780        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        // b has folded everything; a's ack lags at its own second op.
793        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    /// Zero-cap parity, which the constants-only check above cannot see.
835    ///
836    /// That test compares `RUNS_MAX_PER_AGENT`/`RUNS_MAX_AGE_MS` against
837    /// `run_store`'s defaults and asserts behaviour with small numbers — so it
838    /// stays green through exactly the drift car#1338 was about. `RunStore::gc`
839    /// reads a zero cap as DISABLED; this rule has to as well, or the two say
840    /// opposite things about the same value while a doc comment two lines up
841    /// claims they match.
842    #[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            // No count cap: ancient entries still age out.
861            RetentionRule::PerAgentWithAge {
862                max_per_agent: 0,
863                max_age_ms: u64::MAX,
864            },
865            // No age cap: entries past the count cap still go.
866            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        // Behavior with small numbers: keep 2 per agent AND drop older than
906        // age 50 — both restrictive.
907        let mut dev = DeviceLog::new("a");
908        let mut ops = Vec::new();
909        for (id, agent, ts) in [
910            ("r1", "milo", 10u64), // over per-agent cap AND stale
911            ("r2", "milo", 60),    // within both → kept
912            ("r3", "milo", 70),    // within both → kept
913            ("r4", "other", 10),   // within cap, but stale → dropped
914            ("r5", "other", 80),   // kept
915        ] {
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        // Runs carry ids → the trimmed ones leave stubs, not holes.
940        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        // The aged-out trajectory left a stub, not a hole.
993        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        // Aggressive everything-else policy; knowledge/skills still keep all.
1015        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        // And the proposal default keeps the whole multiset.
1039        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        // f3 supersedes f2 supersedes f1; LastN(1) keeps only f3 live —
1047        // and EVERY trimmed id-bearing entry (f0, f1, f2) leaves a stub,
1048        // whether or not anything references it *yet* (the time-hole fix:
1049        // a supersedes that arrives after compaction still resolves).
1050        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        // Idempotent + quota-neutral: re-applying the policy changes
1104        // nothing — stubs are carried, and they don't consume f3's slot.
1105        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"})), // undated
1125        ];
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        // plan_compaction defaults to the derived value — pure over the
1134        // below-frontier ops, so every device agrees with no out-of-band
1135        // coordination.
1136        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        // age(t1) = 100 - 40 = 60 > 30 → stubbed; t2 lives.
1146        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        // All ops sit ABOVE the acked frontier (a device acked long ago and
1158        // never caught up): nothing is droppable.
1159        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        // And it is still loadable the normal way (no truncation happened).
1203        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        // A second compaction over the truncated journal would fold a
1230        // checkpoint missing the prior one's state — refused, naming the
1231        // checkpoint to re-anchor on.
1232        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        // B5 keep-all guard: a non-keep-all rule on the leased `intent` surface
1264        // is rejected loudly (mirroring the event-stream guard), so the
1265        // committed-run idempotency oracle can never be configured away.
1266        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        // keep-all is fine and preserves the committed oracle untouched.
1283        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        // C2 REPRO: a committed run below the stable frontier is truncated from
1290        // the journal by compaction. Its record must NOT be lost — the
1291        // fence-independent committed-run oracle is carried keep-all into the
1292        // checkpoint, so idempotency survives compaction.
1293        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        // A later, higher-epoch tail op (above the frontier) raises the fence.
1308        ops.push(b.append(
1309            Scope::Personal,
1310            Surface::Intent,
1311            Intent::new("milo", "S", 2, IntentStatus::Pending).payload(),
1312        ));
1313
1314        // Frontier cut so committed R (epoch 1) is BELOW and dropped.
1315        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        // The raw R op is gone from the retained tail, but the oracle survives
1326        // in the checkpoint — the idempotency answer is durable.
1327        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}