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 { max_per_agent: usize, max_age_ms: u64 },
98}
99
100/// Per-surface retention policy: surface tag → rule, with a conservative
101/// keep-all default for unknown surfaces. Applies to the grow-only log tier
102/// only — the LWW registry tier is already one compact record per id, and
103/// the proposal's table assigns it no retention.
104#[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    /// Retain everything — the exact-checkpoint policy (compaction still
118    /// drops ops; the snapshot just keeps their full folded state).
119    pub fn keep_all() -> Self {
120        Self {
121            rules: BTreeMap::new(),
122            default_rule: RetentionRule::KeepAll,
123        }
124    }
125
126    /// The proposal's retention table. Two rows are deployment-configurable
127    /// in the proposal itself and therefore parameters here: conversations'
128    /// `N` ("= today's `max_turns`" — a memgine config value, not a global
129    /// constant) and trajectories' `D` days (given as `max_age_ms`). Runs
130    /// use the shipped `RunStore::gc` constants
131    /// ([`RUNS_MAX_PER_AGENT`]/[`RUNS_MAX_AGE_MS`]); knowledge, skills, and
132    /// routing observations keep all (routing's keep-all is also enforced
133    /// structurally — see [`CompactError::EventStreamRetention`]).
134    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/// What retention did: per-surface counts of entries dropped entirely
160/// (id-less — nothing can reference them) and the entries reduced to
161/// tombstone stubs, as `(surface_tag, key)`.
162#[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
178/// The deterministic default reference instant for age-based retention:
179/// the max payload `"timestamp"` across `ops` (0 when none carry one).
180/// Pure over the same inputs the fold consumes, so every device compacting
181/// the same frontier derives the identical value — no out-of-band
182/// agreement, no clock read. Conservative by construction: a stale max
183/// under-drops (age rules see everything as newer), and undated entries
184/// never age-drop regardless.
185pub 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        // Future-stamped entries have age 0 (saturating) — kept.
192        Some(ts) => as_of_ms.saturating_sub(ts) <= max_age_ms,
193        // Undated data is never age-dropped.
194        None => true,
195    }
196}
197
198/// Deterministic recency order: ascending `(timestamp, hlc, op_id)`, with a
199/// missing timestamp sorting as newest (never preferentially dropped).
200fn 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            // Rank per agent over ALL entries (recency), then apply both
234            // caps restrictively — RunStore::gc's exact semantics.
235            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
255/// The entity id a folded log entry answers to for tombstone stubs: the
256/// `id:` key form, else the payload's own `"id"`.
257fn 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
262/// Is this record a retention tombstone stub? (`"tombstone": true` is the
263/// reserved marker [`apply_retention`] stamps.)
264pub 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
272/// The minimal stub a retention-dropped entry leaves behind: the entity id
273/// plus the tombstone marker, under the record's original `op_id`/`hlc`.
274/// Deterministic from the dropped record, so every device reduces it to the
275/// identical stub. Stubbing is idempotent (a stub of a stub is itself).
276fn 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
284/// Apply per-surface retention to a folded (checkpoint) state — the
285/// proposal's "the snapshot applies each surface's retention", with the
286/// §"Two free properties fall out" referential-integrity rule realized as
287/// **universal tombstone stubs**: every dropped id-bearing entry is reduced
288/// to `{"id", "tombstone": true}` (original `op_id`/`hlc` kept) rather than
289/// erased, so a `"supersedes"` reference — even one that arrives after
290/// compaction — always resolves. Stubs never count against a rule's quota
291/// and are carried unchanged by later passes. Pure over its inputs;
292/// `as_of_ms` is the reference instant for the age rules (no clock reads
293/// here — [`plan_compaction`] defaults it via [`as_of_from_ops`]).
294pub fn apply_retention(
295    state: &SyncState,
296    policy: &RetentionPolicy,
297    as_of_ms: u64,
298) -> Result<(SyncState, RetentionReport), CompactError> {
299    // Path-dependent REPLAY surfaces (routing's EMA) are retention-forbidden:
300    // the folded result is recomputed from the ordered multiset, so any trim
301    // silently corrupts every device's replay. Reject loudly.
302    //
303    // This is NARROWER than "event stream": conversation turns are an
304    // op_id-keyed multiset too (B2 — so two genuine same-content turns never
305    // collapse), but they are INDEPENDENT entries, so `LastN` over them is
306    // well-defined and MUST be allowed (conversations need last-N retention).
307    // Only replay streams are rejected here — see `Surface::is_replay_stream`.
308    // Routing is the only one today; its tag is the guard key.
309    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    // Leased execution intents (B5) are KEEP-ALL, structurally protected.
318    // `state.intents` (incl. the fence-independent `committed_runs` idempotency
319    // oracle) is carried untouched below — the fold routes `Surface::Intent`
320    // into `state.intents`, never `state.logs`, so retention here cannot reach
321    // it. A retention rule keyed on "intent" would therefore be a silent no-op
322    // that misleads a reader into thinking committed records get trimmed; reject
323    // it loudly so the keep-all invariant is explicit and reviewable, mirroring
324    // the event-stream guard. (The idempotency oracle surviving compaction
325    // depends on this: a committed run's record must NEVER be trimmable.)
326    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        // Selection ranks LIVE entries only: existing stubs are carried
338        // unchanged and never displace a live entry from the quota.
339        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/// The fold-frontier / ack bookkeeping B3's relay `ack(frontier)` reports
367/// against: per-device max folded HLC, **monotone-only** advance, persisted
368/// alongside the checkpoint (temp + atomic rename, like everything durable
369/// here). This is the proposal's `acked[device]` table, device-local.
370///
371/// **MUST, binding on B3: an ack asserts durably-folded state.** A device
372/// may report `ack(frontier)` only after the ops at/below that frontier are
373/// durably persisted on it (journal-durable, fold applied) — an ack sent
374/// from memory ahead of the fsync lets compaction drop ops the acking
375/// device then loses in a crash, which is exactly the data loss the stable
376/// frontier exists to prevent. The mirror of B1's
377/// journal-durable-before-transmit rule.
378#[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    /// Advance a device's acked frontier. Monotone-only: an ack at or below
389    /// the current frontier is ignored (returns `false`) — a delayed or
390    /// replayed ack can never move GC eligibility backwards.
391    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    /// The stable frontier — `min(acked[d])` over every known device
411    /// (proposal §"Snapshots"). Ops at or below it are folded by everyone
412    /// and are the only truncation candidates. `None` when no device has
413    /// acked (nothing is ever droppable then).
414    pub fn stable_frontier(&self) -> Option<&Hlc> {
415        self.acked.values().min()
416    }
417
418    /// Durably persist (temp + atomic rename); pairs with
419    /// [`AckTable::load`]. Kept alongside the checkpoint directory by
420    /// convention.
421    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    /// Load a persisted table; a missing file is an empty table (a fresh
445    /// device knows of no acks — and an empty table makes compaction
446    /// refuse, the safe default).
447    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/// A compaction failure. No `PartialEq` (carries `io::Error`); match on
457/// variants.
458#[derive(Debug)]
459pub enum CompactError {
460    /// The input log doesn't verify — never compact what you can't trust
461    /// (the B1 verify-before-fold contract).
462    Chain(ChainError),
463    /// No device has acked anything: no stable frontier exists, nothing is
464    /// provably folded-by-everyone, nothing may be dropped.
465    NothingAcked,
466    /// A device present in the log has no ack entry — its fold frontier is
467    /// unknown, so every op is data it may not have seen. Refuse.
468    UnackedDevice { device_id: String },
469    /// The policy tried to trim an event-stream surface (op_id-keyed
470    /// observation multiset). Those replay from genesis; only keep-all is
471    /// sound.
472    EventStreamRetention { surface: String },
473    /// The policy set a non-keep-all rule for the leased `intent` surface
474    /// (B5). Intents are keep-all — the `committed_runs` idempotency oracle
475    /// must survive compaction, so trimming it is never sound.
476    IntentRetention,
477    /// The journal was already truncated below a checkpoint (its truncation
478    /// marker names it). Re-planning from the tail alone would fold a
479    /// checkpoint that silently misses everything the prior checkpoint
480    /// covers — recompaction over a checkpoint base is a later slice.
481    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/// A computed (not yet executed) compaction: the retained checkpoint, the
521/// ops that stay in the journal, and what happened.
522#[derive(Debug)]
523pub struct CompactionPlan {
524    /// The checkpoint to persist BEFORE truncating — frontier heads +
525    /// retention-applied state, content-addressed over the whole record.
526    pub checkpoint: Checkpoint,
527    /// Ops strictly above the stable frontier — the journal's new content.
528    pub retained_ops: Vec<OpRecord>,
529    /// How many ops fall at/below the frontier (dropped from the journal,
530    /// covered by the checkpoint).
531    pub dropped_ops: usize,
532    /// The stable frontier the plan cut at.
533    pub frontier: Hlc,
534    /// The effective reference instant the age rules ran with (the caller's
535    /// explicit value, or [`as_of_from_ops`] over the below-frontier ops).
536    pub as_of_ms: u64,
537    /// What retention trimmed inside the checkpoint state.
538    pub retention: RetentionReport,
539}
540
541/// Plan a compaction of `ops` at the [`AckTable`]'s stable frontier. Pure —
542/// no file IO; [`compact_and_truncate`] executes a plan durably. See the
543/// module docs for the three safety guards; per-device HLC monotonicity
544/// (enforced by `verify_log`) guarantees "hlc ≤ frontier" is a chain prefix,
545/// so the cut is always anchorable.
546///
547/// `as_of_ms`: the age rules' reference instant. `None` (the default every
548/// caller should want) derives it deterministically via [`as_of_from_ops`]
549/// over the below-frontier ops, so identical frontiers yield identical
550/// retained checkpoints on every device.
551pub 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    // Exact fold at the frontier first (from_ops re-verifies the prefix),
573    // then retention on the snapshot only — never on the tail.
574    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/// The outcome of an executed compaction.
589#[derive(Debug)]
590pub struct CompactionOutcome {
591    /// Where the checkpoint landed (content-addressed file in
592    /// `checkpoint_dir`) — `None` when the plan dropped nothing (nothing at
593    /// or below the frontier), in which case neither a checkpoint write nor
594    /// a truncation happened: an empty compaction is a no-op, not an empty
595    /// checkpoint file.
596    pub checkpoint_path: Option<PathBuf>,
597    pub plan: CompactionPlan,
598}
599
600/// Execute a compaction end-to-end on a live journal, enforcing the
601/// crash-ordering invariant by construction:
602///
603/// 1. load + verify the journal (a journal already carrying a truncation
604///    marker is refused — [`CompactError::TruncatedJournal`] — because
605///    re-planning from the tail alone would silently lose the prior
606///    checkpoint's state);
607/// 2. plan at the ack table's stable frontier (a plan that drops nothing
608///    is a **no-op**: no checkpoint written, journal untouched);
609/// 3. **checkpoint durable FIRST** ([`Checkpoint::save`]: temp + fsync +
610///    atomic rename);
611/// 4. only then truncate the journal to the retained tail
612///    ([`OplogJournal::truncate_to`]: temp + atomic rename under the
613///    journal's advisory lock, stamping the truncation marker that fences
614///    `DeviceLog::resume`).
615///
616/// A crash between 3 and 4 leaves the full journal plus a redundant
617/// checkpoint; rerunning is idempotent (same frontier ⇒ same
618/// content-addressed checkpoint file, rename-over-identical). A crash
619/// inside 4 leaves either the old or the new journal, whole. Acknowledged
620/// data is never lost. (Steps are inherently sequential — each depends on
621/// the previous one's durability.)
622pub 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        // Nothing at or below the frontier: writing an empty checkpoint and
637        // rewriting the journal to itself would be pure churn. No-op.
638        return Ok(CompactionOutcome { checkpoint_path: None, plan });
639    }
640    // INVARIANT: checkpoint durable BEFORE any op leaves the journal.
641    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        // Regression is ignored — a replayed/late ack can't move GC back.
668        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        // No temp file left behind.
687        assert!(!path.parent().unwrap().join("acks.json.tmp").exists());
688    }
689
690    /// Ops: device a emits 3 knowledge facts, b (having observed) emits 1.
691    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        // A device in the log with no ack entry → refuse: its fold frontier
716        // is unknown.
717        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        // b has folded everything; a's ack lags at its own second op.
730        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        // Behavior with small numbers: keep 2 per agent AND drop older than
774        // age 50 — both restrictive.
775        let mut dev = DeviceLog::new("a");
776        let mut ops = Vec::new();
777        for (id, agent, ts) in [
778            ("r1", "milo", 10u64),  // over per-agent cap AND stale
779            ("r2", "milo", 60),     // within both → kept
780            ("r3", "milo", 70),     // within both → kept
781            ("r4", "other", 10),    // within cap, but stale → dropped
782            ("r5", "other", 80),    // kept
783        ] {
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        // Runs carry ids → the trimmed ones leave stubs, not holes.
805        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        // The aged-out trajectory left a stub, not a hole.
839        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        // Aggressive everything-else policy; knowledge/skills still keep all.
853        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        // And the proposal default keeps the whole multiset.
877        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        // f3 supersedes f2 supersedes f1; LastN(1) keeps only f3 live —
885        // and EVERY trimmed id-bearing entry (f0, f1, f2) leaves a stub,
886        // whether or not anything references it *yet* (the time-hole fix:
887        // a supersedes that arrives after compaction still resolves).
888        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        // Idempotent + quota-neutral: re-applying the policy changes
926        // nothing — stubs are carried, and they don't consume f3's slot.
927        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"})), // undated
939        ];
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        // plan_compaction defaults to the derived value — pure over the
944        // below-frontier ops, so every device agrees with no out-of-band
945        // coordination.
946        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        // age(t1) = 100 - 40 = 60 > 30 → stubbed; t2 lives.
955        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        // All ops sit ABOVE the acked frontier (a device acked long ago and
967        // never caught up): nothing is droppable.
968        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        // And it is still loadable the normal way (no truncation happened).
990        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        // A second compaction over the truncated journal would fold a
1012        // checkpoint missing the prior one's state — refused, naming the
1013        // checkpoint to re-anchor on.
1014        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        // B5 keep-all guard: a non-keep-all rule on the leased `intent` surface
1041        // is rejected loudly (mirroring the event-stream guard), so the
1042        // committed-run idempotency oracle can never be configured away.
1043        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        // keep-all is fine and preserves the committed oracle untouched.
1058        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        // C2 REPRO: a committed run below the stable frontier is truncated from
1065        // the journal by compaction. Its record must NOT be lost — the
1066        // fence-independent committed-run oracle is carried keep-all into the
1067        // checkpoint, so idempotency survives compaction.
1068        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        // A later, higher-epoch tail op (above the frontier) raises the fence.
1083        ops.push(b.append(
1084            Scope::Personal,
1085            Surface::Intent,
1086            Intent::new("milo", "S", 2, IntentStatus::Pending).payload(),
1087        ));
1088
1089        // Frontier cut so committed R (epoch 1) is BELOW and dropped.
1090        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        // The raw R op is gone from the retained tail, but the oracle survives
1098        // in the checkpoint — the idempotency answer is durable.
1099        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}