Skip to main content

flint_lean/
state.rs

1//! Durable emptyDir bookkeeping (plan §2.1 "restart matrix").
2//!
3//! Everything the barrier needs to survive a CONTAINER restart lives
4//! here as small JSON files under `<root>/.flint-sync/`, each written
5//! temp+rename. A POD replacement gets a fresh emptyDir and therefore a
6//! fresh identity — that asymmetry is deliberate (the plan's P4: the
7//! incarnation id is emptyDir-scoped, so only the same pod's restarted
8//! container may self-supersede the lease).
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15
16use super::{LeanError, LeanResult};
17
18/// One published path as this syncer last knew it: the handle it
19/// integrated (the citation repair re-cites it, the merge recognises it
20/// as this tree's own) and the ETag that attests its bytes.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct BaselineEntry {
23    pub etag: String,
24    /// The HANDLE this tree integrated at the path — its own upload, the
25    /// entry it consumed, the citation it checked out (design 2026-09-19,
26    /// R1-R2). The merge counts a document citing it as citing THIS tree's
27    /// version, not a foreign one. `None` only on a baseline written
28    /// before handles.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub key: Option<String>,
31    pub generation: u64,
32    pub size: u64,
33    pub mtime_unix: i64,
34    /// Sub-second mtime, when this entry was written by a syncer that
35    /// stamps it; `None` compares as "unknown" (`scan::stat_changed`).
36    #[serde(default)]
37    pub mtime_nanos: Option<u32>,
38    /// CRC-64/NVME (wire form) of the object bytes this syncer
39    /// integrated at this path — computed from the bytes it wrote or
40    /// uploaded, never from a HEAD: content convergence compares it, and
41    /// it is what lets the manifest carry a CRC for an object whose
42    /// backend returns none (Ozone). Every writer sets it; `None` only on
43    /// a baseline from before it existed.
44    pub crc64_b64: Option<String>,
45}
46
47/// The persisted baseline snapshot: what this syncer believes the
48/// bucket holds AND has integrated locally — and, since P1-lite
49/// (2026-09-25), the three-way MERGE BASE too. A separate merge base
50/// existed because a tree could adopt a UI write the document did not
51/// cite yet; under P2 every UI edit commits first, so no tree holds a
52/// version the document never cited, and the split had nothing left to
53/// keep apart (the sandbox pair `LeanCoreP1`/`LeanCoreP2R`).
54#[derive(Debug, Clone, Default, Serialize, Deserialize)]
55pub struct Baseline {
56    /// Manifest seq at our last install/checkout.
57    pub seq: u64,
58    /// Manifest document ETag we expect at the next CAS.
59    pub manifest_etag: Option<String>,
60    pub entries: BTreeMap<String, BaselineEntry>,
61    /// THE CHEAP PATH'S RECORD (scan trigger). The pointer etag of the
62    /// document the last full consume derived against, with nothing left
63    /// but the agent's own work; `None` = derive at the next consume (a
64    /// failed fetch, a sync, a baseline from before this field). Only the
65    /// consume writes it — and the commit, when its CAS replaced exactly
66    /// this document, so the new one is it plus this tree's own changes.
67    #[serde(default)]
68    pub derived_etag: Option<String>,
69    /// The paths that derive left untaken because the agent is working on
70    /// them (dirty against the baseline where the document differs). The
71    /// cheap path holds only while every one is still dirty: an agent that
72    /// backs out makes the document's version owed.
73    #[serde(default)]
74    pub skipped: BTreeSet<String>,
75    /// Paths present at the PREVIOUS scan (the two-consecutive-scans
76    /// deletion rule: absence must survive two scans).
77    pub prev_scan: BTreeSet<String>,
78}
79
80/// A rescope in flight (scoped-read design §4.3). `target` is the scope
81/// the workspace is moving TO; `None` means the whole tree.
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
83pub struct ScopeIntent {
84    pub target: Option<Vec<String>>,
85    /// The paths this rescope is removing from the held set, recorded
86    /// BEFORE the first mutation.
87    ///
88    /// Load-bearing, and the mutation check that found it: replay
89    /// cannot RE-DERIVE this set. Once a path is out of
90    /// `baseline.entries` it is indistinguishable from a file the agent
91    /// just created — same scan row, same absence from the baseline —
92    /// and the two have opposite correct answers: unlink the leftover,
93    /// KEEP the agent's new file. Deriving the drop set from the
94    /// baseline made a crash between the uncite and the unlink leave
95    /// six uncited files on disk forever; deriving it from the tree
96    /// would delete the agent's work instead.
97    #[serde(default)]
98    pub drop: Vec<String>,
99    /// What the uncite dropped, per path, recorded BEFORE the uncite (and
100    /// kept across a replay, which can no longer read it off the
101    /// baseline). The unlink takes a file only while its bytes are still
102    /// these: the agent may write a dropped path between the uncite and
103    /// the unlink — a crash leaves that window open until the next
104    /// barrier — and once uncited, its write and the leftover read alike
105    /// (the model's `LeanP1UnlinkBlind`, 2026-10-02).
106    #[serde(default)]
107    pub held: BTreeMap<String, BaselineEntry>,
108}
109
110/// The pod-incarnation identity + lease bookkeeping ({last_token,
111/// quiet_polls} persist so container restarts RESUME the takeover
112/// observation instead of resetting it — plan §2.1).
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct Incarnation {
115    pub holder_id: String,
116    pub epoch: u64,
117    pub last_token: Option<String>,
118    pub quiet_polls: u32,
119}
120
121/// What the conditional-write probes saw this store do
122/// (`conformance.rs`). Cached because the probes cost ~10 requests and
123/// a store does not change its mind between two barriers — but keyed by
124/// `identity`, because a workspace re-pointed at a different endpoint or
125/// bucket is a different store and must be asked again.
126///
127/// `probe_version` is what makes the cache safe to extend: a build that
128/// asks a NEW question must not read an old verdict as the answer to
129/// it, so a bump invalidates every cached verdict rather than inheriting
130/// a silent pass.
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
132pub struct StoreConformance {
133    pub identity: String,
134    pub probe_version: u32,
135    /// Whether the probe got an ANSWER at all. False means it could not
136    /// write its own object — a credential, a policy, the network — and
137    /// the two flags below say nothing. Never cached false: an outage
138    /// must not become this workspace's standing verdict.
139    pub answered: bool,
140    /// `If-None-Match:*` and `If-Match` on PUT — the manifest CAS, the
141    /// upload gate, the lease cell. Nothing works without it.
142    pub conditional_put: bool,
143    /// `If-Match` on DELETE — the file collector, and only it.
144    pub conditional_delete: bool,
145    /// What the failing probe said, for the operator message.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub detail: Option<String>,
148    pub at_unix: u64,
149}
150
151/// The intent journal: the flush id of the barrier in flight, and the H10
152/// carrier. (Its `keys`, `recent_uuids` and `declared_deletes` served the
153/// slot's AdoptOwn and the cell's declared removals; nothing read them
154/// once both were gone — M6 of the 2026-09-24 simplification analysis.)
155#[derive(Debug, Clone, Default, Serialize, Deserialize)]
156pub struct IntentJournal {
157    pub flush_uuid: String,
158    /// What the installs that CARRIED the standing publish declaration
159    /// published, journalled in the write that follows each CAS (review
160    /// 2026-09-18, H10). The ack reads the barrier that honors the
161    /// declaration, and that is not always the one that carried it: a
162    /// restart after step 7, an ack write that failed, or an honor that
163    /// failed before the floor's cadence barrier published the
164    /// declaration, each left the ack to a later barrier — and when a
165    /// peer had deleted a declared path in between, that barrier was a
166    /// pull of the peer's document, answered `ok` while the tree still
167    /// held the declared bytes. Carried forward across barriers; matched
168    /// on the pending record's `id`.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub carrier: Option<Carrier>,
171}
172
173/// See `IntentJournal::carrier`.
174#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
175pub struct Carrier {
176    /// `PendingSentinel::id` of the declaration these installs carried.
177    pub pending_id: String,
178    /// The dirty paths they published — uploads and deletes.
179    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
180    pub paths: BTreeSet<String>,
181    /// Which of `paths` were DELETES: a document that does not cite one
182    /// of those is the declaration honoured, not dropped.
183    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
184    pub deletes: BTreeSet<String>,
185    /// Carried uploads the document the LAST barrier ended at does not
186    /// cite with the bytes this writer published: a peer deleted them after
187    /// this writer published them, or replaced them (L-137: deleted, then
188    /// renamed another file onto the path). The ack's document is that
189    /// one, so it says so (H10). Recomputed at every barrier's end from the
190    /// document in hand; no request.
191    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
192    pub uncited: BTreeSet<String>,
193    /// The bytes each carried upload published: the `crc64_b64` of the
194    /// entry its CAS wrote. What `uncited` compares the document against.
195    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
196    pub published: BTreeMap<String, String>,
197    /// Declared paths they did not carry (parked, deferred, or a delete
198    /// the merge outranked) — the ack's `report.dropped` survives them.
199    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
200    pub dropped: BTreeSet<String>,
201}
202
203/// One surfaced conflict: both versions stay recoverable (local bytes in
204/// the tree, foreign bytes preserved at `preserved_key` or still current
205/// under the data key). Never a silent winner.
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct ConflictRecord {
208    pub path: String,
209    pub foreign_etag: String,
210    /// Where the foreign bytes were preserved (server-side copy), when
211    /// the local version is about to overwrite them.
212    pub preserved_key: Option<String>,
213    pub kind: String, // "consume-dirty" | "upload-412-parked" | "sync-dirty" | "gc-skip"
214    pub at_unix: u64,
215}
216
217pub struct SyncerState {
218    dir: PathBuf,
219    /// The state-directory occupancy lock (flock, held for the process
220    /// lifetime). Self-recognition of the lease via the persisted
221    /// incarnation id is only sound because the PREVIOUS process is
222    /// gone — and this lock is what makes that true. Without it, a
223    /// second flint-sync on the same workspace self-recognizes,
224    /// deposes a LIVE sibling, and both write the tree concurrently
225    /// (observed on the 0b rig: a diagnostic re-run raced a live 1M
226    /// checkout into tmp-rename ENOENT collisions). The hub has the
227    /// identical gate (`state_backend::is_single_occupant`).
228    _lock: std::fs::File,
229}
230
231const MARKER: &str = "checkout-complete";
232/// Written by the SIGTERM drain only after it published: the node
233/// plugin's evidence that a worker that is now gone actually drained
234/// (audit 2026-09-03, finding 3). Absent ⇒ the tree is preserved.
235pub const DRAINED: &str = "drained.json";
236const LOCK: &str = "lock";
237const BASELINE: &str = "baseline.json";
238/// The admitted set of a SCOPED checkout. Deliberately its own
239/// document rather than a field on `Baseline`: the baseline is
240/// rewritten by every barrier, every sync and every consume, and a
241/// scope that rides along is one `Baseline::default()` away from
242/// silently widening a workspace to the whole tree. This file is
243/// written once, by checkout, and thereafter only read. Absent means
244/// UNSCOPED — the only encoding of "no restriction", since an empty
245/// admitted set is refused before it can be written.
246const SCOPE: &str = "scope.json";
247
248/// A rescope IN FLIGHT: the target scope, written before the first
249/// mutation and removed only after the last (scoped-read design §4.3).
250///
251/// Its own document and not a field on `scope.json`, because the two
252/// answer different questions — `scope.json` is what this workspace
253/// holds, this is what it is on its way to holding — and a crash must
254/// leave both answers readable.
255///
256/// `null` is a LEGAL target (the whole tree), so absence of the file
257/// means "no rescope in flight", never "unscoped". That is why the
258/// document wraps the target in a struct instead of being a bare
259/// `Option<Vec<String>>`: a bare `null` on disk and a missing file
260/// would deserialize to the same thing.
261const SCOPE_INTENT: &str = "scope-intent.json";
262const INCARNATION: &str = "incarnation.json";
263const INTENT: &str = "intent.json";
264/// When this writer last swept for untracked uploads (`untracked.rs`).
265const UNTRACKED_SWEEP: &str = "untracked-sweep";
266const ORPHAN_SWEEP: &str = "orphan-sweep";
267/// The retire logs this writer has read (`untracked.rs`): immutable, so each
268/// is fetched once.
269const RETIRE_CACHE: &str = "retire-cache.json";
270/// The cached conditional-write verdict for this store (`conformance.rs`).
271const CONFORMANCE: &str = "conformance.json";
272const CONFLICTS: &str = "conflicts.jsonl";
273/// The rotated generation. `load_conflicts` reads it FIRST, so the
274/// sequence a reader sees is unbroken across a rotation — which matters
275/// because `honor_sync` takes the count before a sync and `skip`s it
276/// after, and a rotation that SHORTENED the list would make it skip past
277/// the very records the sync just produced.
278const CONFLICTS_PREV: &str = "conflicts.1.jsonl";
279/// How many records were dropped when a SECOND rotation overwrote the
280/// first. Truncation that nobody can count is truncation that reads as
281/// "there were no more".
282const CONFLICTS_DROPPED: &str = "conflicts.dropped";
283/// Rotate past this. The file is append-only and `load_conflicts` parses
284/// it WHOLE — twice per sync honor, again per status read and per
285/// scrape (U23) — so an unbounded file is an unbounded parse on a hot
286/// path, and a standing condition (a UI autosaving over a path the agent
287/// holds dirty) appends on every barrier. Two generations bound the
288/// resident set at ~2 MiB, which is thousands of records.
289const CONFLICTS_MAX_BYTES: u64 = 1 << 20;
290
291fn write_atomic(path: &Path, bytes: &[u8]) -> LeanResult<()> {
292    // The state dir lives in the same app-writable emptyDir.
293    super::safefs::check_parent(path)?;
294    let tmp = path.with_extension("tmp");
295    super::safefs::write_via_tmp(path, &tmp, bytes, None).map(|_| ())
296}
297
298impl SyncerState {
299    pub fn open(dir: PathBuf) -> LeanResult<SyncerState> {
300        fs::create_dir_all(&dir)?;
301        let lock = fs::OpenOptions::new()
302            .create(true)
303            .truncate(false)
304            .write(true)
305            .open(dir.join(LOCK))?;
306        #[cfg(unix)]
307        {
308            use std::os::fd::AsRawFd;
309            // SAFETY: flock on an owned, open fd.
310            let rc = unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
311            if rc != 0 {
312                return Err(LeanError::State(format!(
313                    "another flint-sync already holds this workspace ({}): \
314                     refusing to run two syncers over one tree",
315                    dir.display()
316                )));
317            }
318        }
319        Ok(SyncerState { dir, _lock: lock })
320    }
321
322    pub fn marker_present(&self) -> bool {
323        self.dir.join(MARKER).exists()
324    }
325
326    /// Flush the filesystem holding the tree (the state dir lives in
327    /// it) so everything materialised so far is on stable storage.
328    pub fn sync_tree(&self) -> LeanResult<()> {
329        super::safefs::sync_tree(&self.dir)
330    }
331
332    /// The drain's attestation. `seq` is the manifest the drain left
333    /// installed, when it knows it.
334    pub fn write_drained(&self, seq: Option<u64>, acks: usize) -> LeanResult<()> {
335        let doc = serde_json::json!({
336            "unix": super::now_unix(),
337            "seq": seq,
338            "acks": acks,
339        });
340        write_atomic(&self.dir.join(DRAINED), doc.to_string().as_bytes())
341    }
342
343    /// A fresh incarnation owes its own attestation; a stale one from
344    /// an earlier life of this tree must not vouch for it.
345    pub fn clear_drained(&self) -> LeanResult<()> {
346        match fs::remove_file(self.dir.join(DRAINED)) {
347            Ok(()) => Ok(()),
348            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
349            Err(e) => Err(LeanError::State(format!("clear {DRAINED}: {e}"))),
350        }
351    }
352
353    pub fn drained_path(&self) -> PathBuf {
354        self.dir.join(DRAINED)
355    }
356
357    /// Written LAST at checkout: the agent-start gate.
358    pub fn write_marker(&self) -> LeanResult<()> {
359        write_atomic(&self.dir.join(MARKER), b"ok\n")
360    }
361
362    pub fn load_baseline(&self) -> LeanResult<Baseline> {
363        let p = self.dir.join(BASELINE);
364        if !p.exists() {
365            return Ok(Baseline::default());
366        }
367        let bytes = fs::read(&p)?;
368        serde_json::from_slice(&bytes).map_err(|e| LeanError::State(format!("baseline: {e}")))
369    }
370
371    /// COMPACT: the baseline is the other O(files) document, rewritten
372    /// on the cadence tick and re-parsed several times per barrier. The
373    /// agent-facing O(1) files (gauges, capabilities, acks) stay pretty
374    /// — those exist to be `cat`-ed.
375    pub fn save_baseline(&self, b: &Baseline) -> LeanResult<()> {
376        let bytes =
377            serde_json::to_vec(b).map_err(|e| LeanError::State(format!("baseline: {e}")))?;
378        write_atomic(&self.dir.join(BASELINE), &bytes)
379    }
380
381    /// What this workspace was admitted to hold, or `None` for an
382    /// unscoped (whole-manifest) checkout.
383    ///
384    /// `fs::read` directly, with NO `exists()` pre-check: `exists()`
385    /// answers false for EACCES and EIO alike, and an unreadable scope
386    /// answered as "unscoped" is the same class of bug as an unreadable
387    /// path answered as "deleted" (`barrier.rs`, 14b3637c). Only
388    /// NotFound means absent.
389    pub fn load_scope(&self) -> LeanResult<Option<Vec<String>>> {
390        let p = self.dir.join(SCOPE);
391        let bytes = match fs::read(&p) {
392            Ok(b) => b,
393            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
394            Err(e) => {
395                return Err(LeanError::State(format!(
396                    "cannot read the checkout scope at {}: {e} — refusing to \
397                     guess what this workspace is holding",
398                    p.display()
399                )))
400            }
401        };
402        serde_json::from_slice(&bytes).map_err(|e| LeanError::State(format!("scope: {e}")))
403    }
404
405    /// `Some` writes the admitted set; `None` REMOVES the document.
406    /// Removing matters: a scoped checkout that crashed before its
407    /// marker leaves a scope behind, and the whole-tree checkout that
408    /// replaces it must not inherit a claim it did not make.
409    pub fn save_scope(&self, scope: Option<&[String]>) -> LeanResult<()> {
410        let p = self.dir.join(SCOPE);
411        match scope {
412            Some(entries) => {
413                let bytes = serde_json::to_vec(entries)
414                    .map_err(|e| LeanError::State(format!("scope: {e}")))?;
415                write_atomic(&p, &bytes)
416            }
417            None => match fs::remove_file(&p) {
418                Ok(()) => Ok(()),
419                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
420                Err(e) => Err(LeanError::State(format!("clear {SCOPE}: {e}"))),
421            },
422        }
423    }
424
425    /// Read a rescope in flight. `None` = none in flight.
426    pub fn load_scope_intent(&self) -> LeanResult<Option<ScopeIntent>> {
427        let p = self.dir.join(SCOPE_INTENT);
428        let bytes = match fs::read(&p) {
429            Ok(b) => b,
430            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
431            Err(e) => {
432                return Err(LeanError::State(format!(
433                    "cannot read the rescope intent at {}: {e} — refusing to run a barrier \
434                     over a half-applied scope",
435                    p.display()
436                )))
437            }
438        };
439        serde_json::from_slice(&bytes)
440            .map(Some)
441            .map_err(|e| LeanError::State(format!("scope intent: {e}")))
442    }
443
444    pub fn save_scope_intent(&self, intent: &ScopeIntent) -> LeanResult<()> {
445        let bytes = serde_json::to_vec(intent)
446            .map_err(|e| LeanError::State(format!("scope intent: {e}")))?;
447        write_atomic(&self.dir.join(SCOPE_INTENT), &bytes)
448    }
449
450    /// Cleared LAST, after the new scope is durable. Clearing it first
451    /// would turn a crash into a workspace whose scope says one thing
452    /// and whose held set says another, with nothing left to replay.
453    pub fn clear_scope_intent(&self) -> LeanResult<()> {
454        match fs::remove_file(self.dir.join(SCOPE_INTENT)) {
455            Ok(()) => Ok(()),
456            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
457            Err(e) => Err(LeanError::State(format!("clear {SCOPE_INTENT}: {e}"))),
458        }
459    }
460
461    pub fn load_incarnation(&self) -> LeanResult<Option<Incarnation>> {
462        let p = self.dir.join(INCARNATION);
463        if !p.exists() {
464            return Ok(None);
465        }
466        let bytes = fs::read(&p)?;
467        serde_json::from_slice(&bytes)
468            .map(Some)
469            .map_err(|e| LeanError::State(format!("incarnation: {e}")))
470    }
471
472    pub fn save_incarnation(&self, i: &Incarnation) -> LeanResult<()> {
473        let bytes = serde_json::to_vec_pretty(i)
474            .map_err(|e| LeanError::State(format!("incarnation: {e}")))?;
475        write_atomic(&self.dir.join(INCARNATION), &bytes)
476    }
477
478    /// Unix seconds of this writer's last untracked-upload sweep; 0 when it
479    /// has never swept.
480    pub fn load_untracked_sweep_at(&self) -> LeanResult<u64> {
481        match fs::read_to_string(self.dir.join(UNTRACKED_SWEEP)) {
482            Ok(s) => Ok(s.trim().parse().unwrap_or(0)),
483            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
484            Err(e) => Err(e.into()),
485        }
486    }
487
488    pub fn save_untracked_sweep_at(&self, at: u64) -> LeanResult<()> {
489        write_atomic(&self.dir.join(UNTRACKED_SWEEP), at.to_string().as_bytes())
490    }
491
492    /// The orphan sweep's clock (`untracked.rs`, `sweep_orphans_if_due`):
493    /// 0 = never swept, and the first commit starts it.
494    pub fn load_orphan_sweep_at(&self) -> LeanResult<u64> {
495        match fs::read_to_string(self.dir.join(ORPHAN_SWEEP)) {
496            Ok(s) => Ok(s.trim().parse().unwrap_or(0)),
497            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
498            Err(e) => Err(e.into()),
499        }
500    }
501
502    pub fn save_orphan_sweep_at(&self, at: u64) -> LeanResult<()> {
503        write_atomic(&self.dir.join(ORPHAN_SWEEP), at.to_string().as_bytes())
504    }
505
506    /// The retire logs read so far, by key. A cache: unreadable reads as
507    /// empty (the logs are fetched again), never as an error.
508    pub fn load_retire_cache(&self) -> BTreeMap<String, super::manifest::RetireLog> {
509        fs::read(self.dir.join(RETIRE_CACHE)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
510    }
511
512    pub fn save_retire_cache(&self, c: &BTreeMap<String, super::manifest::RetireLog>) -> LeanResult<()> {
513        let bytes = serde_json::to_vec(c).map_err(|e| LeanError::State(format!("retire cache: {e}")))?;
514        write_atomic(&self.dir.join(RETIRE_CACHE), &bytes)
515    }
516
517    /// A verdict that cannot be parsed reads as ABSENT, never as a pass:
518    /// the probe runs again. The expensive outcome of a corrupt cache is
519    /// ten requests; the cheap one would be running the collector on a
520    /// store nobody asked.
521    pub fn load_conformance(&self) -> LeanResult<Option<StoreConformance>> {
522        match fs::read(self.dir.join(CONFORMANCE)) {
523            Ok(b) => Ok(serde_json::from_slice(&b).ok()),
524            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
525            Err(e) => Err(e.into()),
526        }
527    }
528
529    pub fn save_conformance(&self, c: &StoreConformance) -> LeanResult<()> {
530        let bytes = serde_json::to_vec_pretty(c)
531            .map_err(|e| LeanError::State(format!("conformance: {e}")))?;
532        write_atomic(&self.dir.join(CONFORMANCE), &bytes)
533    }
534
535    pub fn load_intent(&self) -> LeanResult<IntentJournal> {
536        let p = self.dir.join(INTENT);
537        if !p.exists() {
538            return Ok(IntentJournal::default());
539        }
540        let bytes = fs::read(&p)?;
541        serde_json::from_slice(&bytes).map_err(|e| LeanError::State(format!("intent: {e}")))
542    }
543
544    pub fn save_intent(&self, j: &IntentJournal) -> LeanResult<()> {
545        let bytes =
546            serde_json::to_vec_pretty(j).map_err(|e| LeanError::State(format!("intent: {e}")))?;
547        write_atomic(&self.dir.join(INTENT), &bytes)
548    }
549
550    /// The barrier is over: no flush in flight. The carrier stays.
551    pub fn clear_intent_keys(&self) -> LeanResult<()> {
552        let mut j = self.load_intent()?;
553        j.flush_uuid = String::new();
554        self.save_intent(&j)
555    }
556
557    pub fn append_conflict(&self, c: &ConflictRecord) -> LeanResult<()> {
558        use std::io::Write;
559        let line =
560            serde_json::to_string(c).map_err(|e| LeanError::State(format!("conflict: {e}")))?;
561        let live = self.dir.join(CONFLICTS);
562        let mut f = fs::OpenOptions::new().create(true).append(true).open(&live)?;
563        writeln!(f, "{line}")?;
564        // Size off the handle we just wrote through — no extra stat of
565        // the path, and no line count, which would make every append
566        // O(records).
567        let len = f.metadata()?.len();
568        drop(f);
569        if len > CONFLICTS_MAX_BYTES {
570            self.rotate_conflicts()?;
571        }
572        Ok(())
573    }
574
575    /// Rotate live -> `.1`, counting whatever the previous `.1` held so
576    /// the loss is a NUMBER rather than a silence. Best effort: a
577    /// rotation that fails leaves a large file, which is a performance
578    /// problem; losing the record we just appended would be a
579    /// correctness one.
580    fn rotate_conflicts(&self) -> LeanResult<()> {
581        let live = self.dir.join(CONFLICTS);
582        let prev = self.dir.join(CONFLICTS_PREV);
583        // Only read the outgoing generation at ROTATION time — rare by
584        // construction, since it takes CONFLICTS_MAX_BYTES to get here.
585        if let Ok(text) = fs::read_to_string(&prev) {
586            let dropped = text.lines().filter(|l| !l.trim().is_empty()).count() as u64;
587            let total = self.conflicts_dropped()? + dropped;
588            write_atomic(&self.dir.join(CONFLICTS_DROPPED), total.to_string().as_bytes())?;
589        }
590        fs::rename(&live, &prev)?;
591        Ok(())
592    }
593
594    /// Records lost to rotation. A caller rendering conflicts owes the
595    /// user this number: a truncated list that does not say it was
596    /// truncated reads as a complete one.
597    pub fn conflicts_dropped(&self) -> LeanResult<u64> {
598        match fs::read_to_string(self.dir.join(CONFLICTS_DROPPED)) {
599            Ok(s) => Ok(s.trim().parse().unwrap_or(0)),
600            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
601            Err(e) => Err(LeanError::State(format!("{CONFLICTS_DROPPED}: {e}"))),
602        }
603    }
604
605    /// Every record ever appended: the ones rotation dropped plus the
606    /// ones held. Unlike the held count it never goes down, so a reader
607    /// that takes it before an operation knows exactly how many records
608    /// the operation produced, across any rotation (`conflicts_since`).
609    pub fn conflicts_appended(&self) -> LeanResult<u64> {
610        Ok(self.conflicts_dropped()? + self.load_conflicts()?.len() as u64)
611    }
612
613    /// The rotated generation first, then the live one: oldest to
614    /// newest, unbroken across a single rotation.
615    pub fn load_conflicts(&self) -> LeanResult<Vec<ConflictRecord>> {
616        let mut out = vec![];
617        for name in [CONFLICTS_PREV, CONFLICTS] {
618            let p = self.dir.join(name);
619            // `fs::read_to_string` directly: `exists()` answers false
620            // for EACCES and EIO alike, and a conflict log that reads as
621            // EMPTY because it is unreadable is the same shape of bug as
622            // an unreadable path reading as deleted.
623            let text = match fs::read_to_string(&p) {
624                Ok(t) => t,
625                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
626                Err(e) => {
627                    return Err(LeanError::State(format!(
628                        "cannot read {name}: {e} — refusing to report an unreadable \
629                         conflict log as an empty one"
630                    )))
631                }
632            };
633            for line in text.lines() {
634                if line.trim().is_empty() {
635                    continue;
636                }
637                out.push(
638                    serde_json::from_str(line)
639                        .map_err(|e| LeanError::State(format!("conflict line: {e}")))?,
640                );
641            }
642        }
643        Ok(out)
644    }
645}