Skip to main content

flint_lean/
manifest.rs

1//! The lean subtree manifest: one JSON document, CAS-written, the
2//! atomic commit point every reader trusts (checkout, sync, DR).
3//!
4//! Deliberately NOT `tier::manifest`: the hub's writer rebuilds the
5//! whole document from its local walk and cannot merge (its Entry has
6//! no author identity), which is the amputation engine the lean review
7//! reproduced three ways. This writer merges three-way:
8//! base = the manifest view at OUR last install, mine = this barrier's
9//! walk, theirs = bucket-current. Foreign entries are preserved and
10//! surfaced for the next consume; a local delete loses to a foreign
11//! modify (delete/modify resolves conservative — the formal model's
12//! GC-vs-merge counterexample is exactly this case).
13
14use std::collections::{BTreeMap, BTreeSet};
15
16use serde::{Deserialize, Serialize};
17
18use flint_store::{
19    crc64_nvme, GenerationStamps, ObjectMeta, ObjectStore, PutCondition, StoreError,
20};
21
22use super::{LeanConfig, LeanError, LeanResult};
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25pub struct LeanEntry {
26    /// Object key (under `<prefix>/files/`).
27    pub key: String,
28    pub etag: String,
29    /// Full-object CRC-64/NVME of the cited bytes, in wire (base64)
30    /// form — computed by the flint client that MOVED the bytes (the
31    /// publisher over what it uploaded, a consume or checkout over what
32    /// it wrote), never copied from a backend header. It is the one
33    /// integrity check that holds on every backend: S3 attests a
34    /// checksum on the wire, Ozone attests none, and the manifest's
35    /// value describes the bytes either way. Every reader verifies a
36    /// fresh fetch against it before the file becomes visible.
37    pub crc64_b64: String,
38    pub size: u64,
39    pub mode: u32,
40    pub mtime_unix: i64,
41    pub generation: u64,
42    /// The publishing writer's lease epoch (0 = a gateway/HITL write).
43    pub epoch: u64,
44}
45
46#[derive(Debug, Clone, Default, Serialize, Deserialize)]
47pub struct LeanManifest {
48    pub seq: u64,
49    /// path -> entry. Paths are workspace-relative, '/'-separated.
50    pub entries: BTreeMap<String, LeanEntry>,
51    /// This workspace is published by exactly ONE writer, so an object
52    /// that has moved off its citation was moved by something that is
53    /// not the publisher.
54    ///
55    /// It exists because a reader's default arm on a 412 is an
56    /// explicit S3-wins adopt — right for a lean workspace, where an
57    /// object past its citation means a human wrote newer bytes that
58    /// ought to win. On a MIRROR that reading is exactly inverted: the
59    /// publisher is the only party entitled to write, so "past the
60    /// citation" means a stranger did, and adopting copies bytes no
61    /// manifest cites into a reader's tree with no error and no
62    /// conflict record. Composition drill C4 measured that against
63    /// forge's legible export.
64    ///
65    /// Set by the installing pass from config, never inherited.
66    #[serde(default)]
67    pub sole_writer: bool,
68    /// Which citation source installed this manifest (§2.4.1). Also
69    /// stamped on the object's metadata so it is readable by HEAD.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub boundary_source: Option<String>,
72    /// What each published delete RETIRED, path -> the etag the document
73    /// cited before the delete, kept until the path is cited again (or
74    /// `TOMBSTONE_KEEP_SEQS` generations pass). Review 2026-09-18, H1e: a
75    /// writer that adopted a UI write while it was still pending, and
76    /// merges after another writer cited it and then deleted it knowingly,
77    /// otherwise sees only "baseline ≠ merge base, object at the key" —
78    /// the same view a delete that RACED the write leaves, where modify
79    /// rightly wins — and the citation repair (removed in step 5) resurrected
80    /// the deleted generation. The tombstone is the one fact that tells the
81    /// two apart.
82    /// Empty on every document written before this field existed.
83    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
84    pub tombstones: BTreeMap<String, Tombstone>,
85}
86
87/// A published delete's record in the document: the generation it retired
88/// and the generation of the document that published it.
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
90pub struct Tombstone {
91    pub etag: String,
92    pub seq: u64,
93}
94
95/// How many document generations a tombstone outlives its delete. A writer
96/// whose merge base is older than this can still re-cite what it holds —
97/// the residual, named in SAFETY.md §4.14.
98pub const TOMBSTONE_KEEP_SEQS: u64 = 10_000;
99
100impl LeanManifest {
101    /// COMPACT, not pretty. This document is O(entries) and it moves on
102    /// every path that matters: the pre-marker checkout GET, every
103    /// barrier merge GET and CAS PUT, and every gateway read verb. The
104    /// indentation was ~22% of those bytes and of the parse that
105    /// follows. `mc cat | jq` — the operator's and every rig's only
106    /// debugging surface for the CAS commit point — is unaffected.
107    pub fn to_bytes(&self) -> Vec<u8> {
108        serde_json::to_vec(self).expect("manifest serializes")
109    }
110    pub fn parse(bytes: &[u8]) -> Result<LeanManifest, String> {
111        serde_json::from_slice(bytes).map_err(|e| e.to_string())
112    }
113}
114
115/// The bucket-current manifest + its document ETag (the CAS token).
116/// The pointer document at `<prefix>/.flint/lean/current` — the ONLY
117/// mutable metadata object under the pointer layout (design of record:
118/// `docs/plans/flint-lean-manifest-pointer-design.md`).
119///
120/// Everything a reader needs before it decides whether to fetch entries
121/// at all lives here, so the barrier's idle tick reads a few hundred
122/// bytes instead of a document that runs to 264 MiB at 1M entries.
123#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
124pub struct Pointer {
125    /// The manifest generation this pointer publishes. Bumped by a
126    /// citation AND by a takeover rotation, which is why it is not the
127    /// same number as `entries_seq`.
128    pub seq: u64,
129    /// The single immutable object holding every entry of this
130    /// generation — the step-one layout. `None` on a CHUNKED pointer.
131    ///
132    /// Optional here and REQUIRED in the step-one binary's schema, and
133    /// that asymmetry is the migration: a step-one reader handed a
134    /// chunked pointer fails to parse it and refuses, rather than
135    /// decoding a pointer it half-understands. `manifest::load` maps a
136    /// missing object to `Ok(None)`, `None` means FIRST WRITE, and a
137    /// barrier answers that with `If-None-Match: *` — so every layout
138    /// change has to make an old reader refuse rather than conclude the
139    /// project is empty. **Do not give this field a serde default in a
140    /// released binary; that is what makes the refusal work.**
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub entries_key: Option<String>,
143    /// The `seq` the entries object was written under. A rotation
144    /// leaves this ALONE while bumping `seq`, which is exactly what
145    /// lets a follower skip the entries GET after a takeover.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub entries_seq: Option<u64>,
148    /// The chunk list — the step-two layout. `Some(vec![])` is a
149    /// legitimately EMPTY project and is distinguishable from absence,
150    /// which is why this is an Option around a Vec rather than a bare
151    /// Vec with a default.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub chunks: Option<Vec<super::chunk::ChunkRef>>,
154    /// Carried here as well as on the manifest because the pointer is
155    /// the authority for the fields it carries, and a reader that
156    /// reconstructs a manifest from it must not lose the flag that
157    /// decides whether it may adopt.
158    #[serde(default)]
159    pub sole_writer: bool,
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub boundary_source: Option<String>,
162    /// The epoch that installed this pointer. Diagnostic; the fence is
163    /// the CAS, not this field.
164    pub epoch: u64,
165    /// The content address of the document's tombstones in the chunked
166    /// layout (`chunk::TombstoneBody`, at `chunk_key`), `None` when the
167    /// document has none. The single-object layout carries them inline.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub tombstones: Option<String>,
170}
171
172/// Where a pointer's entries actually live.
173#[derive(Debug)]
174pub enum Entries<'a> {
175    /// One generation object (step one).
176    Single { key: &'a str, seq: u64 },
177    /// A chunk list (step two). Empty = an empty project.
178    Chunked(&'a [super::chunk::ChunkRef]),
179}
180
181impl Pointer {
182    /// Resolve the layout, refusing anything ambiguous.
183    ///
184    /// BOTH forms present, or NEITHER, is malformed and must fail —
185    /// never pick one. A pointer carrying both would let two readers
186    /// that broke the tie differently disagree about the contents of
187    /// the same seq, which is the one thing a single visible object was
188    /// supposed to make impossible.
189    pub fn entries(&self) -> LeanResult<Entries<'_>> {
190        match (&self.entries_key, &self.chunks) {
191            (Some(k), None) => {
192                Ok(Entries::Single { key: k, seq: self.entries_seq.unwrap_or(self.seq) })
193            }
194            (None, Some(c)) => Ok(Entries::Chunked(c)),
195            (Some(_), Some(_)) => Err(super::LeanError::State(format!(
196                "manifest pointer at seq {} names BOTH a generation object and a chunk list; \
197                 refusing to guess which describes this manifest",
198                self.seq
199            ))),
200            (None, None) => Err(super::LeanError::State(format!(
201                "manifest pointer at seq {} names no entries at all — neither a generation \
202                 object nor a chunk list; an empty project is an empty chunk LIST, not a \
203                 missing one",
204                self.seq
205            ))),
206        }
207    }
208}
209
210/// What a writer must present to CAS. Carries the LAYOUT as well as the
211/// tag, because a workspace mid-migration has a legacy manifest and no
212/// pointer yet, and the two cases take different preconditions: an
213/// If-Match on the object the writer read, versus an If-None-Match on a
214/// pointer that must not already exist.
215#[derive(Debug, Clone, PartialEq)]
216pub struct ManifestHandle {
217    pub etag: String,
218    /// True when the document was read from the LEGACY single-object
219    /// key, i.e. this write is the migration.
220    pub legacy: bool,
221    /// The chunk list this write is superseding, empty if the workspace
222    /// is not chunked yet. Carried on the handle rather than re-read at
223    /// publish time because the caller has just loaded it, and because
224    /// a publish that had to GET the pointer again to find out what it
225    /// already knew would pay a request per barrier for nothing.
226    ///
227    /// This is what makes a publish O(changed): a chunk whose address
228    /// is already here is not sent.
229    pub prev_chunks: Vec<super::chunk::ChunkRef>,
230    /// The tombstones object the superseded pointer names, if any: retired
231    /// with the chunks when the new pointer names another (M1).
232    pub prev_tombstones: Option<String>,
233}
234
235/// What the legacy key is overwritten with once a workspace has moved to
236/// the pointer layout. It must NOT parse as a manifest: `LeanManifest`
237/// gives `seq` and `entries` no serde default, so an OLD `flint-sync`
238/// reading this gets `LeanError::State` and refuses.
239///
240/// Deleting the legacy key instead would be a data-loss shape, not a
241/// tidy-up: `manifest::load` maps a missing object to `Ok(None)`, `None`
242/// means FIRST WRITE, and a barrier answers that with
243/// `IfNoneMatchAny` — so an old syncer would conclude the project is
244/// empty and re-seed over it.
245pub const REFUSAL_DOC: &str = concat!(
246    "{\"moved\":\".flint/lean/current\",",
247    "\"note\":\"this workspace uses the manifest pointer layout; upgrade flint-sync\"}"
248);
249
250#[derive(Clone)]
251pub struct LoadedManifest {
252    pub manifest: LeanManifest,
253    /// The CAS handle: the POINTER's etag under the pointer layout, the
254    /// legacy object's etag before migration.
255    pub etag: String,
256    /// The object's own Last-Modified. The GET already carries it; a
257    /// caller that wants the coherence stamp must not pay a second
258    /// request for what this one threw away (review: U31).
259    pub last_modified_unix: Option<u64>,
260    /// `None` ⇒ this workspace is still on the legacy single-object
261    /// layout and the next write migrates it.
262    pub pointer: Option<Pointer>,
263}
264
265impl LoadedManifest {
266    /// The handle a writer presents to CAS over this read.
267    pub fn handle(&self) -> ManifestHandle {
268        ManifestHandle {
269            etag: self.etag.clone(),
270            legacy: self.pointer.is_none(),
271            prev_chunks: match self.pointer.as_ref().map(|p| p.entries()) {
272                Some(Ok(Entries::Chunked(c))) => c.to_vec(),
273                _ => Vec::new(),
274            },
275            prev_tombstones: self.pointer.as_ref().and_then(|p| p.tombstones.clone()),
276        }
277    }
278}
279
280/// Read the pointer alone. The barrier's idle tick wants exactly this
281/// and nothing else: a few hundred bytes that answer "did anything
282/// move, and were the ENTRIES part of it".
283pub struct LoadedPointer {
284    pub pointer: Pointer,
285    pub etag: String,
286    /// The pointer object's own Last-Modified — the citation's clock,
287    /// which /status reports and which the GET already carries.
288    pub last_modified_unix: Option<u64>,
289}
290
291pub async fn load_pointer(
292    store: &dyn ObjectStore,
293    cfg: &LeanConfig,
294) -> LeanResult<Option<LoadedPointer>> {
295    match store.get_whole(&cfg.current_key(), None).await {
296        Ok((meta, bytes)) => {
297            let pointer: Pointer = serde_json::from_slice(&bytes)
298                .map_err(|e| super::LeanError::State(format!("manifest pointer parse: {e}")))?;
299            Ok(Some(LoadedPointer { pointer, etag: meta.etag, last_modified_unix: meta.last_modified_unix }))
300        }
301        Err(StoreError::NotFound(_)) => Ok(None),
302        Err(e) => Err(e.into()),
303    }
304}
305
306/// The whole document: pointer, then the generation it names. Falls
307/// back to the legacy single-object key for a workspace that has not
308/// been migrated — which every existing workspace is, exactly once.
309/// How many times `load` will restart onto a newer generation before
310/// giving up. A restart happens only when a chunk we needed was swept
311/// AND the pointer moved under us, so it is bounded by publish rate,
312/// not by anything we control — three is generous for a workspace whose
313/// floor is measured in seconds.
314pub(crate) const LOAD_ATTEMPTS: u32 = 3;
315
316/// What `fetch_chunks` found for one pointer.
317enum Fetched {
318    /// Every chunk's body, in the pointer's order and each verified against
319    /// its address, and the decoded tombstones.
320    Complete { bodies: Vec<bytes::Bytes>, tombstones: BTreeMap<String, Tombstone> },
321    /// Something the pointer names is not in the bucket.
322    Missing(Hole),
323}
324
325/// The object a load found missing.
326pub(crate) enum Hole {
327    Chunk { addr: String, first: String },
328    Tombstones(String),
329}
330
331impl Hole {
332    pub(crate) fn refusal(&self, seq: u64) -> String {
333        match self {
334            Hole::Chunk { addr, first } => format!(
335                "manifest pointer at seq {seq} names chunk {addr} (first key {first:?}), which does \
336                 not exist — refusing to serve a manifest with a hole in it as though those entries \
337                 had been deleted"
338            ),
339            Hole::Tombstones(addr) => {
340                format!("manifest pointer at seq {seq} names tombstones {addr}, which do not exist")
341            }
342        }
343    }
344}
345
346/// The bodies of `refs` and the pointer's tombstones (latency plan lever A).
347///
348/// 1. The cache, where it has a body that hashes to the address asked for.
349///    One that does not is dropped and fetched: the cache vouches for
350///    nothing it has not just proved.
351/// 2. The rest from the bucket, `chunk_fetch_parallelism` at a time — they
352///    were one at a time, 220 sequential GETs at a million entries.
353/// 3. Beside those, ONE LIST of the chunk prefix when anything came from
354///    the cache: a body is proof of content, not of the object still being
355///    there, and a chunk the live pointer names that the bucket lost must
356///    still refuse the load. A LIST the credential may not make (or that
357///    fails) sends the cached chunks to the bucket instead: the check is
358///    never skipped, only paid for the old way.
359///
360/// Every fetched body is checked against its address before it is kept or
361/// used, so `chunk::assemble_verified` hashes nothing twice.
362async fn fetch_chunks(
363    store: &dyn ObjectStore,
364    cfg: &LeanConfig,
365    p: &Pointer,
366    refs: &[super::chunk::ChunkRef],
367) -> LeanResult<Fetched> {
368    use futures::stream::{self, StreamExt};
369    let cache = cfg.chunk_cache.as_deref();
370    let mut bodies: Vec<Option<bytes::Bytes>> = vec![None; refs.len()];
371    let mut tombstones: Option<BTreeMap<String, Tombstone>> = None;
372    if let Some(c) = cache {
373        for (slot, r) in bodies.iter_mut().zip(refs) {
374            if let Some(b) = c.get(&r.addr) {
375                if super::chunk::chunk_address(&b) == r.addr {
376                    *slot = Some(b);
377                } else {
378                    c.remove(&r.addr);
379                }
380            }
381        }
382        if let Some(addr) = &p.tombstones {
383            if let Some(b) = c.get(addr) {
384                match super::chunk::decode_tombstones(&b, addr) {
385                    Ok(t) => tombstones = Some(t),
386                    Err(_) => c.remove(addr),
387                }
388            }
389        }
390    }
391    let cached: Vec<usize> = (0..refs.len()).filter(|&i| bodies[i].is_some()).collect();
392    let tombstones_cached = tombstones.is_some();
393
394    // What to fetch: the misses, and on a failed LIST the hits as well.
395    let fetch_round = |want_chunks: Vec<usize>, want_tombstones: bool| async move {
396        let mut wants: Vec<(Option<usize>, String)> =
397            want_chunks.into_iter().map(|i| (Some(i), refs[i].addr.clone())).collect();
398        if want_tombstones {
399            if let Some(a) = &p.tombstones {
400                wants.push((None, a.clone()));
401            }
402        }
403        stream::iter(wants.into_iter().map(|(slot, addr)| async move {
404            let got = store.get_whole(&cfg.chunk_key(&addr), None).await;
405            (slot, addr, got)
406        }))
407        .buffer_unordered(cfg.chunk_fetch_parallelism.max(1))
408        .collect::<Vec<_>>()
409        .await
410    };
411    let misses: Vec<usize> = (0..refs.len()).filter(|&i| bodies[i].is_none()).collect();
412    let tombstones_missing = p.tombstones.is_some() && !tombstones_cached;
413    let listing = async {
414        if cached.is_empty() && !tombstones_cached {
415            return None;
416        }
417        Some(store.list(&format!("{}/{}/chunks/", cfg.prefix, super::LEAN_DIR)).await)
418    };
419    let (listing, fetched) = futures::join!(listing, fetch_round(misses, tombstones_missing));
420    let mut rounds = vec![fetched];
421    match listing {
422        None => {}
423        Some(Ok(listed)) => {
424            let present: std::collections::HashSet<&str> =
425                listed.iter().filter_map(|o| o.key.rsplit('/').next()).collect();
426            for &i in &cached {
427                if !present.contains(refs[i].addr.as_str()) {
428                    return Ok(Fetched::Missing(Hole::Chunk {
429                        addr: refs[i].addr.clone(),
430                        first: refs[i].first.clone(),
431                    }));
432                }
433            }
434            if let (true, Some(addr)) = (tombstones_cached, &p.tombstones) {
435                if !present.contains(addr.as_str()) {
436                    return Ok(Fetched::Missing(Hole::Tombstones(addr.clone())));
437                }
438            }
439        }
440        Some(Err(_)) => {
441            // Existence by the old route: fetch what the cache served.
442            rounds.push(fetch_round(cached, tombstones_cached).await);
443        }
444    }
445    for (slot, addr, got) in rounds.into_iter().flatten() {
446        let body = match got {
447            Ok((_, b)) => b,
448            Err(StoreError::NotFound(_)) => {
449                return Ok(Fetched::Missing(match slot {
450                    Some(i) => Hole::Chunk { addr, first: refs[i].first.clone() },
451                    None => Hole::Tombstones(addr),
452                }));
453            }
454            Err(e) => return Err(e.into()),
455        };
456        match slot {
457            Some(i) => {
458                // The object was fetched BY address, so a mismatch means
459                // the store handed back something else — a stale read, a
460                // wrong key, a truncated body.
461                super::chunk::check_address_of(&body, &addr)?;
462                if let Some(c) = cache {
463                    c.put(&addr, &body);
464                }
465                bodies[i] = Some(body);
466            }
467            None => {
468                tombstones = Some(super::chunk::decode_tombstones(&body, &addr)?);
469                if let Some(c) = cache {
470                    c.put(&addr, &body);
471                }
472            }
473        }
474    }
475    Ok(Fetched::Complete {
476        bodies: bodies.into_iter().map(|b| b.expect("every chunk is cached or fetched")).collect(),
477        tombstones: tombstones.unwrap_or_default(),
478    })
479}
480
481pub async fn load(
482    store: &dyn ObjectStore,
483    cfg: &LeanConfig,
484) -> LeanResult<Option<LoadedManifest>> {
485    load_from(store, cfg, None).await
486}
487
488/// [`load`], starting from a pointer read the caller ALREADY made
489/// (`Some(None)`: it 404'd), so the first attempt does not GET it again —
490/// the consume's cheap path reads the pointer and, when it has moved or is
491/// absent, needs the document behind that same read. A restart onto a
492/// newer generation reads the pointer afresh, as `load` always did.
493pub async fn load_from(
494    store: &dyn ObjectStore,
495    cfg: &LeanConfig,
496    mut first: Option<Option<LoadedPointer>>,
497) -> LeanResult<Option<LoadedManifest>> {
498  for attempt in 0..LOAD_ATTEMPTS {
499    let read = match first.take() {
500        Some(already) => already,
501        None => load_pointer(store, cfg).await?,
502    };
503    if let Some(LoadedPointer { pointer: p, etag, last_modified_unix: pointer_lm }) = read {
504        // A pointer naming an object that is not there is not an empty
505        // workspace — it is a broken one, and answering `None` would
506        // invite a re-seed over a live project. That rule holds one
507        // level deeper for chunks: a chunk list with a hole is a
508        // manifest missing entries, and a missing entry reads to every
509        // consumer as a file the agent deleted.
510        let (mut manifest, meta_lm) = match p.entries()? {
511            Entries::Single { key, .. } => {
512                let (meta, bytes) = match store.get_whole(key, None).await {
513                    Ok(v) => v,
514                    Err(StoreError::NotFound(_)) => {
515                        return Err(super::LeanError::State(format!(
516                            "manifest pointer at seq {} names {key}, which does not exist — \
517                             refusing to treat a broken pointer as an empty workspace",
518                            p.seq
519                        )))
520                    }
521                    Err(e) => return Err(e.into()),
522                };
523                let m = LeanManifest::parse(&bytes)
524                    .map_err(|e| super::LeanError::State(format!("manifest parse: {e}")))?;
525                (m, meta.last_modified_unix)
526            }
527            Entries::Chunked(refs) => {
528                // The bodies come from the chunk cache where it has them and
529                // from the bucket in parallel where it does not (latency
530                // plan lever A, `chunkcache`). A hole FAILS rather than
531                // silently shortening the manifest, however the bodies came.
532                let (bodies, tombstones) = match fetch_chunks(store, cfg, &p, refs).await? {
533                    Fetched::Complete { bodies, tombstones } => (bodies, tombstones),
534                    Fetched::Missing(hole) => {
535                        // A chunk we need is gone. Which of two very
536                        // different things happened is decided by the
537                        // POINTER, not by the chunk:
538                        //
539                        // - the pointer MOVED ⇒ we raced a sweep that
540                        //   collected a generation we were still reading.
541                        //   The current generation is a complete snapshot,
542                        //   so start over against it: coherent, just newer.
543                        //   This is what makes a reader safe for a DURATION
544                        //   rather than for `Retain` publishes (chunked
545                        //   design §8.2, machine-checked by
546                        //   `LeanChunkGCSlowReader`).
547                        // - the pointer is UNCHANGED ⇒ the live manifest
548                        //   genuinely has a hole, and there is nothing newer
549                        //   to restart onto. Fail, and never as `Ok(None)`,
550                        //   which every caller reads as FIRST WRITE.
551                        let moved = match load_pointer(store, cfg).await? {
552                            Some(now) => now.etag != etag,
553                            None => true,
554                        };
555                        if moved && attempt + 1 < LOAD_ATTEMPTS {
556                            continue;
557                        }
558                        return Err(super::LeanError::State(hole.refusal(p.seq)));
559                    }
560                };
561                let entries = super::chunk::assemble_verified(refs, &bodies)?;
562                super::docview::remember(cfg, refs, &entries);
563                // What the cache keeps from here is this document's chunks:
564                // the next load needs these and whatever is new.
565                if let Some(c) = cfg.chunk_cache.as_deref() {
566                    let mut live: std::collections::HashSet<String> = refs.iter().map(|r| r.addr.clone()).collect();
567                    live.extend(p.tombstones.clone());
568                    c.retain(&live);
569                }
570                (
571                    LeanManifest {
572                        seq: p.seq,
573                        entries,
574                        sole_writer: p.sole_writer,
575                        boundary_source: p.boundary_source.clone(),
576                        tombstones,
577                    },
578                    None,
579                )
580            }
581        };
582        // The pointer is the authority for the fields it carries: a
583        // rotation moves `seq` without rewriting the entries object, so
584        // the generation's own copy is stale by construction.
585        manifest.seq = p.seq;
586        manifest.sole_writer = p.sole_writer;
587        manifest.boundary_source = p.boundary_source.clone();
588        return Ok(Some(LoadedManifest {
589            manifest,
590            etag,
591            // The CITATION's clock is when the pointer moved, not when
592            // the entries object happened to be written: a rotation
593            // republishes the same entries under a new generation.
594            last_modified_unix: pointer_lm.or(meta_lm),
595            pointer: Some(p),
596        }));
597    }
598    // No pointer: the pre-migration layout, which has no chunks and so
599    // nothing to race a sweep over. Answer it and leave the loop.
600    return match store.get_whole(&cfg.manifest_key(), None).await {
601        Ok((meta, bytes)) => {
602            let manifest = LeanManifest::parse(&bytes)
603                .map_err(|e| super::LeanError::State(format!("manifest parse: {e}")))?;
604            Ok(Some(LoadedManifest {
605                manifest,
606                etag: meta.etag,
607                last_modified_unix: meta.last_modified_unix,
608                pointer: None,
609            }))
610        }
611        Err(StoreError::NotFound(_)) => Ok(None),
612        Err(e) => Err(e.into()),
613    };
614  }
615  // Every attempt raced a sweep. Refusing is right: the alternative is
616  // to serve a manifest we know is short, and short reads as deleted.
617  Err(super::LeanError::State(format!(
618      "manifest load restarted {LOAD_ATTEMPTS} times without settling — a sweep is \
619       collecting generations faster than this reader can finish one"
620  )))
621}
622
623/// How many lost races a one-CAS edit re-reads and retries before it gives
624/// up `Contended`.
625pub const EDIT_CAS_ATTEMPTS: usize = 16;
626
627/// Why a one-CAS edit did not land.
628#[derive(Debug)]
629pub enum EditError<E> {
630    /// The edit refused the document it was shown.
631    Refused(E),
632    Store(LeanError),
633    /// Lost `EDIT_CAS_ATTEMPTS` races in a row.
634    Contended,
635}
636
637/// ONE manifest CAS for an edit by a party that holds no lease (P2): the
638/// gateway's UI saves, deletes and renames, and the ingress sweep's
639/// adoptions. Load the current document; let `edit` judge it and change a
640/// copy — the next generation, with the installing party's fields reset as
641/// `merge` resets them; keep tombstones to `TOMBSTONE_KEEP_SEQS`; CAS it in
642/// with epoch 0; on a lost race do it all again against what won, so every
643/// precondition is judged against the document the CAS replaces.
644pub async fn commit_edit<E>(
645    store: &dyn ObjectStore,
646    cfg: &LeanConfig,
647    flush: &str,
648    edit: impl Fn(Option<&LoadedManifest>, &mut LeanManifest) -> Result<(), E>,
649) -> Result<(), EditError<E>> {
650    commit_edit_from(store, cfg, flush, None, edit).await
651}
652
653/// [`commit_edit`], whose FIRST attempt edits `first` — a document the
654/// caller loaded at the pointer it just read (the gateway's `view`, keyed by
655/// the pointer's etag) — instead of loading it again. `Some(None)`: the
656/// caller found no document. Safe because the CAS is If-Match on that
657/// document's etag: an attempt over a document that has since moved loses,
658/// and every later attempt loads the current one.
659pub async fn commit_edit_from<E>(
660    store: &dyn ObjectStore,
661    cfg: &LeanConfig,
662    flush: &str,
663    first: Option<Option<std::sync::Arc<LoadedManifest>>>,
664    edit: impl Fn(Option<&LoadedManifest>, &mut LeanManifest) -> Result<(), E>,
665) -> Result<(), EditError<E>> {
666    let mut first = first;
667    // The document the last attempt sent, when its CAS answered 412 or
668    // 409. An SDK retries a conditional PUT whose response was lost, and
669    // the retry meets the object its first attempt wrote: if the document
670    // now IS the one sent, the edit committed, and it is answered so rather
671    // than judged again against itself (a delete would find its path gone
672    // and answer NoSuchFile; the model's GDelete acknowledges what it
673    // commits, `udel`). A peer's identical edit at the same seq (the same
674    // delete, raced) leaves the same document: committed either way.
675    let mut sent: Option<LeanManifest> = None;
676    for _ in 0..EDIT_CAS_ATTEMPTS {
677        let current: Option<std::sync::Arc<LoadedManifest>> = match first.take() {
678            Some(read) => read,
679            None => load(store, cfg).await.map_err(EditError::Store)?.map(std::sync::Arc::new),
680        };
681        if let (Some(s), Some(c)) = (sent.as_ref(), current.as_ref()) {
682            if same_document(s, &c.manifest) {
683                return Ok(());
684            }
685        }
686        let mut doc = current.as_ref().map(|l| l.manifest.clone()).unwrap_or_default();
687        doc.seq += 1;
688        doc.sole_writer = false;
689        doc.boundary_source = None;
690        edit(current.as_deref(), &mut doc).map_err(EditError::Refused)?;
691        let seq = doc.seq;
692        doc.tombstones.retain(|_, t| seq.saturating_sub(t.seq) <= TOMBSTONE_KEEP_SEQS);
693        let expected = current.as_ref().map(|l| l.handle());
694        let before = current.as_ref().map(|l| &l.manifest);
695        match cas_write_retiring(store, cfg, &doc, before, expected.as_ref(), 0, flush, None).await {
696            Ok(_) => return Ok(()),
697            Err(LeanError::Store(StoreError::PreconditionFailed(_))) | Err(LeanError::Store(StoreError::Conflict(_))) => {
698                sent = Some(doc);
699                continue;
700            }
701            Err(e) => return Err(EditError::Store(e)),
702        }
703    }
704    Err(EditError::Contended)
705}
706
707/// Is `b` the document `a` is — the same generation with the same
708/// citations, tombstones and stamps? (`commit_edit`'s own-write check.)
709fn same_document(a: &LeanManifest, b: &LeanManifest) -> bool {
710    a.seq == b.seq
711        && a.entries == b.entries
712        && a.tombstones == b.tombstones
713        && a.sole_writer == b.sole_writer
714        && a.boundary_source == b.boundary_source
715}
716
717/// A one-CAS edit of a document it does not copy (the gateway's lever,
718/// latency plan §16): the edit reads the document it was handed and writes
719/// an overlay. `entries` answers as the edited document's map would, so an
720/// edit reads as it does under `commit_edit`; `seq` is the next generation
721/// and `tombstones` the document's, to change.
722pub struct DocEdit<'a> {
723    pub seq: u64,
724    pub entries: EditEntries<'a>,
725    pub tombstones: BTreeMap<String, Tombstone>,
726}
727
728/// A document's entries with an overlay on them (`DocEdit::entries`).
729pub struct EditEntries<'a> {
730    base: &'a BTreeMap<String, LeanEntry>,
731    overlay: BTreeMap<String, Option<LeanEntry>>,
732}
733
734impl EditEntries<'_> {
735    pub fn get(&self, p: &str) -> Option<&LeanEntry> {
736        match self.overlay.get(p) {
737            Some(v) => v.as_ref(),
738            None => self.base.get(p),
739        }
740    }
741
742    pub fn contains_key(&self, p: &str) -> bool {
743        self.get(p).is_some()
744    }
745
746    pub fn insert(&mut self, p: String, e: LeanEntry) -> Option<LeanEntry> {
747        let was = self.get(&p).cloned();
748        self.overlay.insert(p, Some(e));
749        was
750    }
751
752    pub fn remove(&mut self, p: &str) -> Option<LeanEntry> {
753        let was = self.get(p).cloned()?;
754        self.overlay.insert(p.to_string(), None);
755        Some(was)
756    }
757}
758
759/// How `commit_edit_paths` ended, and the document to hold next: the one it
760/// installed, made from the one it was handed in place when nothing else
761/// held that (no reload, no copy); or the one it was handed, unchanged,
762/// when the edit refused it. `None`: load before the next edit.
763pub struct EditOutcome<E> {
764    pub result: Result<(), EditError<E>>,
765    pub held: Option<std::sync::Arc<LoadedManifest>>,
766}
767
768/// [`commit_edit_from`] without copying the document (latency plan §16): the
769/// edit writes an overlay (`DocEdit`); the commit re-chunks only from the
770/// first chunk an overlay path falls in until the chunking lines up again
771/// (`chunk::rechunk_with`), answers the retire set from the overlay and the
772/// chunks' aliases (`docview::retired_over`), and publishes only the chunks
773/// that changed. The document it installs is the one `commit_edit_from`
774/// would; the shadow check (`LeanConfig::g_shadow`, and every debug build)
775/// asserts the same chunk list, chunk bytes and retire set. A document that
776/// is not chunked, or none, is edited whole as before, and so is every
777/// document under `chunk_view = false`.
778pub async fn commit_edit_paths<E>(
779    store: &dyn ObjectStore,
780    cfg: &LeanConfig,
781    flush: &str,
782    first: Option<Option<std::sync::Arc<LoadedManifest>>>,
783    edit: impl Fn(Option<&LoadedManifest>, &mut DocEdit<'_>) -> Result<(), E>,
784) -> EditOutcome<E> {
785    use std::sync::Arc;
786    let mut first = first;
787    // The pointer the last attempt sent, when its CAS answered 412 or 409:
788    // the same own-write check as `commit_edit_from`'s, on the pointer.
789    let mut sent: Option<Pointer> = None;
790    for _ in 0..EDIT_CAS_ATTEMPTS {
791        let current: Option<Arc<LoadedManifest>> = match first.take() {
792            Some(read) => read,
793            None => match load(store, cfg).await {
794                Ok(l) => l.map(Arc::new),
795                Err(e) => return EditOutcome { result: Err(EditError::Store(e)), held: None },
796            },
797        };
798        let chunked: Option<Vec<super::chunk::ChunkRef>> =
799            match current.as_ref().and_then(|l| l.pointer.as_ref()).map(|p| p.entries()) {
800                Some(Ok(Entries::Chunked(r))) if cfg.chunked && cfg.chunk_view => Some(r.to_vec()),
801                _ => None,
802            };
803        let refs = match chunked {
804            Some(r) => r,
805            None => {
806                // Not chunked, nothing published, or the view turned off:
807                // the whole-document edit. A refusal hands back what it read.
808                let read = current.clone();
809                let result = commit_edit_from(store, cfg, flush, Some(current), |cur, doc| {
810                    let base = doc.clone();
811                    let mut de = DocEdit {
812                        seq: doc.seq,
813                        entries: EditEntries { base: &base.entries, overlay: BTreeMap::new() },
814                        tombstones: std::mem::take(&mut doc.tombstones),
815                    };
816                    edit(cur, &mut de)?;
817                    doc.tombstones = de.tombstones;
818                    apply_overlay(&mut doc.entries, de.entries.overlay);
819                    Ok(())
820                })
821                .await;
822                let held = match &result {
823                    Err(EditError::Refused(_)) => read,
824                    _ => None,
825                };
826                return EditOutcome { result, held };
827            }
828        };
829        let cur = current.as_deref().expect("chunked above");
830        if sent.as_ref().is_some_and(|s| cur.pointer.as_ref() == Some(s)) {
831            return EditOutcome { result: Ok(()), held: None };
832        }
833        let base = &cur.manifest;
834        let mut de = DocEdit {
835            seq: base.seq + 1,
836            entries: EditEntries { base: &base.entries, overlay: BTreeMap::new() },
837            tombstones: base.tombstones.clone(),
838        };
839        if let Err(e) = edit(Some(cur), &mut de) {
840            return EditOutcome { result: Err(EditError::Refused(e)), held: current };
841        }
842        let DocEdit { seq, entries, mut tombstones } = de;
843        let overlay = entries.overlay;
844        tombstones.retain(|_, t| seq.saturating_sub(t.seq) <= TOMBSTONE_KEEP_SEQS);
845        let built = super::chunk::rechunk_with(
846            &refs,
847            |i| Ok(Arc::new(super::docview::chunk_entries(&base.entries, &refs, i).map(|(k, e)| (k.clone(), e.clone())).collect())),
848            &overlay,
849            cfg.chunk_target,
850            cfg.chunk_min,
851            cfg.chunk_max,
852        )
853        .and_then(|chunks| {
854            let retired = super::docview::retired_over(cfg, &refs, &base.entries, &overlay);
855            if cfg.g_shadow || cfg!(debug_assertions) {
856                shadow_edit(cfg, base, &overlay, &chunks, &retired);
857            }
858            let tomb = match tombstones.is_empty() {
859                true => None,
860                false => Some(super::chunk::encode_tombstones(&tombstones)?.0),
861            };
862            Ok((chunks, retired, tomb))
863        });
864        let (chunks, retired, tomb) = match built {
865            Ok(b) => b,
866            Err(e) => return EditOutcome { result: Err(EditError::Store(e)), held: None },
867        };
868        let doc = ChunkedDoc { seq, sole_writer: false, boundary_source: None, tombstones: &tombstones };
869        let expected = cur.handle();
870        let sending = Pointer {
871            seq,
872            entries_key: None,
873            entries_seq: None,
874            chunks: Some(chunks.iter().map(|(r, _)| r.clone()).collect()),
875            sole_writer: false,
876            boundary_source: None,
877            epoch: 0,
878            tombstones: tomb,
879        };
880        match publish_chunk_list(store, cfg, &chunks, &doc, Some(&expected), &expected.prev_chunks, 0, flush).await {
881            Ok((meta, chunk_refs, pointer)) => {
882                debug_assert_eq!(pointer, sending, "the pointer published is not the one the own-write check compares");
883                log_retired(store, cfg, seq, 0, flush, retired, Some(&chunk_refs), Some(&expected)).await;
884                // The document to hold next: this one, made from what the
885                // edit read, in place when nothing else holds that.
886                let mut held = current.expect("chunked above");
887                let l = Arc::make_mut(&mut held);
888                apply_overlay(&mut l.manifest.entries, overlay);
889                l.manifest.seq = seq;
890                l.manifest.sole_writer = false;
891                l.manifest.boundary_source = None;
892                l.manifest.tombstones = tombstones;
893                l.etag = meta.etag;
894                // The citation's clock is this write's (a full load reads the
895                // pointer's Last-Modified).
896                l.last_modified_unix = meta.last_modified_unix.or_else(|| Some(super::now_unix()));
897                // What the alias cache keeps is this document's chunks; the
898                // new ones are scanned in the held map when first asked.
899                let live: std::collections::HashSet<&str> =
900                    pointer.chunks.iter().flatten().map(|r| r.addr.as_str()).collect();
901                cfg.alias_cache.lock().unwrap().retain(|a, _| live.contains(a.as_str()));
902                l.pointer = Some(pointer);
903                return EditOutcome { result: Ok(()), held: Some(held) };
904            }
905            Err(LeanError::Store(StoreError::PreconditionFailed(_))) | Err(LeanError::Store(StoreError::Conflict(_))) => {
906                sent = Some(sending);
907                continue;
908            }
909            Err(e) => return EditOutcome { result: Err(EditError::Store(e)), held: None },
910        }
911    }
912    EditOutcome { result: Err(EditError::Contended), held: None }
913}
914
915fn apply_overlay(entries: &mut BTreeMap<String, LeanEntry>, overlay: BTreeMap<String, Option<LeanEntry>>) {
916    for (p, v) in overlay {
917        match v {
918            Some(e) => {
919                entries.insert(p, e);
920            }
921            None => {
922                entries.remove(&p);
923            }
924        }
925    }
926}
927
928/// `commit_edit_paths`'s shadow (tests, and every debug build): the whole
929/// document edited, split and diffed as `commit_edit_from` does, must give
930/// the same chunk list, the same bytes for each chunk sent, and the same
931/// retire set.
932fn shadow_edit(
933    cfg: &LeanConfig,
934    base: &LeanManifest,
935    overlay: &BTreeMap<String, Option<LeanEntry>>,
936    chunks: &[(super::chunk::ChunkRef, Option<Vec<u8>>)],
937    retired: &[String],
938) {
939    let mut whole = base.entries.clone();
940    apply_overlay(&mut whole, overlay.clone());
941    let want = super::chunk::split_with(&whole, cfg.chunk_target, cfg.chunk_min, cfg.chunk_max)
942        .expect("shadow: the whole document does not split");
943    let got_refs: Vec<&super::chunk::ChunkRef> = chunks.iter().map(|(r, _)| r).collect();
944    let want_refs: Vec<&super::chunk::ChunkRef> = want.iter().map(|(r, _)| r).collect();
945    assert_eq!(got_refs, want_refs, "edit shadow: the chunk list is not the whole document's");
946    for ((r, body), (_, w)) in chunks.iter().zip(&want) {
947        if let Some(b) = body {
948            assert_eq!(b, w, "edit shadow: chunk {} encodes differently", r.addr);
949        }
950    }
951    let still: std::collections::HashSet<&str> = whole.values().map(|e| e.key.as_str()).collect();
952    let want_retired: Vec<String> =
953        base.entries.values().map(|e| e.key.clone()).filter(|k| !still.contains(k.as_str())).collect();
954    assert_eq!(retired, want_retired.as_slice(), "edit shadow: the retire set is not the whole document's");
955}
956
957/// CAS-write the manifest document. `expected`: None ⇒ first write
958/// (If-None-Match:*); Some(etag) ⇒ If-Match. The caller owns 412
959/// handling (merge-retry or fence — never blind re-seed).
960pub async fn cas_write(
961    store: &dyn ObjectStore,
962    cfg: &LeanConfig,
963    m: &LeanManifest,
964    expected: Option<&ManifestHandle>,
965    epoch: u64,
966    flush_uuid: &str,
967) -> LeanResult<ObjectMeta> {
968    cas_write_stamped(store, cfg, m, expected, epoch, flush_uuid, None).await
969}
970
971/// `cas_write` with an explicit boundary-source stamp on the manifest
972/// OBJECT (§2.4.1): downstream consumers distinguish a declared-coherent
973/// citation from a forced possibly-torn one from the bucket alone, for
974/// the price of a HEAD rather than a GET of a manifest that can run to
975/// hundreds of MiB.
976pub async fn cas_write_stamped(
977    store: &dyn ObjectStore,
978    cfg: &LeanConfig,
979    m: &LeanManifest,
980    expected: Option<&ManifestHandle>,
981    epoch: u64,
982    flush_uuid: &str,
983    boundary_source: Option<&str>,
984) -> LeanResult<ObjectMeta> {
985    Ok(cas_write_refs(store, cfg, m, expected, epoch, flush_uuid, boundary_source).await?.0)
986}
987
988/// `cas_write_stamped`, also returning the chunk objects the new pointer
989/// references (its chunks and its tombstones object) — `None` on the
990/// single-object layout.
991async fn cas_write_refs(
992    store: &dyn ObjectStore,
993    cfg: &LeanConfig,
994    m: &LeanManifest,
995    expected: Option<&ManifestHandle>,
996    epoch: u64,
997    flush_uuid: &str,
998    boundary_source: Option<&str>,
999) -> LeanResult<(ObjectMeta, Option<Vec<String>>)> {
1000    // The manifest's own document must agree with its object stamp:
1001    // a reader that GETs and a reader that HEADs must never disagree
1002    // about how coherent the citation claims to be.
1003    // The layout switch. Every publisher routes through here — the
1004    // barrier, the takeover rotation and the gateway's HITL CAS — so the
1005    // decision lives at one site rather than three, and §6's "chunk
1006    // server-side on receipt" falls out rather than needing its own
1007    // path.
1008    if cfg.chunked {
1009        let (meta, refs) = cas_write_chunked_refs(
1010            store,
1011            cfg,
1012            m,
1013            expected,
1014            expected.map(|h| h.prev_chunks.as_slice()).unwrap_or(&[]),
1015            PublishStamps { epoch, flush_uuid, boundary_source },
1016        )
1017        .await?;
1018        return Ok((meta, Some(refs)));
1019    }
1020    let mut m = m.clone();
1021    if boundary_source.is_some() {
1022        m.boundary_source = boundary_source.map(|s| s.to_string());
1023    }
1024    let m = &m;
1025    let bytes = m.to_bytes();
1026    let crc = crc64_nvme(&bytes);
1027    let stamps = GenerationStamps {
1028        generation: m.seq,
1029        epoch,
1030        flush_uuid: flush_uuid.to_string(),
1031        // Stamp what the DOCUMENT carries, not merely what this call
1032        // passed. An explicit source has already been written into the
1033        // document above, so the two agree there either way; the case
1034        // this closes is a `None` caller over a document that already
1035        // names its clock — the gateway's HITL CAS relays a document it
1036        // did not author. Stamping `None` on those left the object
1037        // saying nothing while the document said `sentinel`, which is
1038        // exactly the GET/HEAD divergence the contract above forbids.
1039        boundary_source: m.boundary_source.clone(),
1040        posix: None,
1041    };
1042
1043    // 1. The ENTRIES, to a fresh immutable key. `IfNoneMatchAny` is the
1044    //    whole safety story for this object: two writers that raced to
1045    //    the same seq cannot both land, and the loser is told so here
1046    //    rather than discovering it after it has already published.
1047    let entries_key = cfg.generation_key(m.seq, flush_uuid);
1048    match store
1049        .put_whole(&entries_key, bytes.into(), &PutCondition::IfNoneMatchAny, &stamps, crc)
1050        .await
1051    {
1052        Ok(_) => {}
1053        // The key carries this writer's flush uuid, so this is not a
1054        // race with another writer — it is a retry of OUR OWN write
1055        // whose pointer CAS we never learned the outcome of. The object
1056        // is byte-identical by construction; fall through to the CAS,
1057        // which is where the race is actually decided.
1058        Err(StoreError::PreconditionFailed(_)) | Err(StoreError::Conflict(_)) => {}
1059        Err(e) => return Err(e.into()),
1060    }
1061
1062    // 2. The POINTER, which is what makes the publish visible and what
1063    //    decides the race. Everything above this line is invisible to
1064    //    every reader; a crash here leaves an orphan generation object
1065    //    that nobody reads and the sweep collects.
1066    let pointer = Pointer {
1067        seq: m.seq,
1068        entries_key: Some(entries_key.clone()),
1069        entries_seq: Some(m.seq),
1070        // The step-one layout. `cas_write_chunked` is what produces a
1071        // chunk list; this path stays byte-for-byte what it was.
1072        chunks: None,
1073        sole_writer: m.sole_writer,
1074        boundary_source: m.boundary_source.clone(),
1075        epoch,
1076        tombstones: None,
1077    };
1078    Ok((put_pointer(store, cfg, &pointer, expected, &stamps).await?, None))
1079}
1080
1081/// Who is publishing, and under what coherence claim. Grouped because
1082/// they travel together through every publish path and are meaningless
1083/// apart — and because a positional list of three scalars is how an
1084/// epoch ends up where a flush uuid was meant to go.
1085pub struct PublishStamps<'a> {
1086    pub epoch: u64,
1087    pub flush_uuid: &'a str,
1088    pub boundary_source: Option<&'a str>,
1089}
1090
1091/// Publish a manifest as a CHUNK LIST (chunked design §2).
1092///
1093/// The ordering is the atomicity story and is not negotiable: every
1094/// chunk is durable BEFORE the pointer CAS, and the pointer is the only
1095/// thing that ever becomes visible. A crash between the two leaves
1096/// orphan chunks nobody references and no reader can observe — never a
1097/// half-written manifest.
1098///
1099/// `prev` is the chunk list this publish is replacing. Chunks whose
1100/// address is already in it are skipped ENTIRELY — not uploaded and
1101/// re-conditioned, but never sent. That is where O(changed) actually
1102/// comes from: without it, a one-file publish still ships every chunk's
1103/// bytes over the wire and only the store's 412 saves the storage.
1104pub async fn cas_write_chunked(
1105    store: &dyn ObjectStore,
1106    cfg: &LeanConfig,
1107    m: &LeanManifest,
1108    expected: Option<&ManifestHandle>,
1109    prev: &[super::chunk::ChunkRef],
1110    pub_stamps: PublishStamps<'_>,
1111) -> LeanResult<ObjectMeta> {
1112    Ok(cas_write_chunked_refs(store, cfg, m, expected, prev, pub_stamps).await?.0)
1113}
1114
1115/// `cas_write_chunked`, also returning the chunk addresses the new pointer
1116/// references, its tombstones object included.
1117async fn cas_write_chunked_refs(
1118    store: &dyn ObjectStore,
1119    cfg: &LeanConfig,
1120    m: &LeanManifest,
1121    expected: Option<&ManifestHandle>,
1122    prev: &[super::chunk::ChunkRef],
1123    pub_stamps: PublishStamps<'_>,
1124) -> LeanResult<(ObjectMeta, Vec<String>)> {
1125    let boundary_source = pub_stamps.boundary_source.map(|s| s.to_string()).or_else(|| m.boundary_source.clone());
1126    let split = super::chunk::split_with(&m.entries, cfg.chunk_target, cfg.chunk_min, cfg.chunk_max)?;
1127    let chunks: Vec<(super::chunk::ChunkRef, Option<Vec<u8>>)> = split.into_iter().map(|(r, b)| (r, Some(b))).collect();
1128    let refs: Vec<super::chunk::ChunkRef> = chunks.iter().map(|(r, _)| r.clone()).collect();
1129    super::docview::remember(cfg, &refs, &m.entries);
1130    let doc = ChunkedDoc { seq: m.seq, sole_writer: m.sole_writer, boundary_source, tombstones: &m.tombstones };
1131    let (meta, refs, _) =
1132        publish_chunk_list(store, cfg, &chunks, &doc, expected, prev, pub_stamps.epoch, pub_stamps.flush_uuid).await?;
1133    Ok((meta, refs))
1134}
1135
1136/// What a chunked publish writes beside its chunk list.
1137pub struct ChunkedDoc<'a> {
1138    pub seq: u64,
1139    pub sole_writer: bool,
1140    pub boundary_source: Option<String>,
1141    pub tombstones: &'a BTreeMap<String, Tombstone>,
1142}
1143
1144/// Publish a chunk list: every chunk `prev` lacks, then the tombstones
1145/// object, then the pointer CAS. `chunks` carries a body for each chunk it
1146/// encoded; one without a body is `prev`'s and is never sent. The ordering
1147/// is the atomicity story (`cas_write_chunked`). Returns the pointer's
1148/// response, the chunk addresses it references (its tombstones object
1149/// included), and the pointer itself.
1150#[allow(clippy::too_many_arguments)]
1151async fn publish_chunk_list(
1152    store: &dyn ObjectStore,
1153    cfg: &LeanConfig,
1154    chunks: &[(super::chunk::ChunkRef, Option<Vec<u8>>)],
1155    doc: &ChunkedDoc<'_>,
1156    expected: Option<&ManifestHandle>,
1157    prev: &[super::chunk::ChunkRef],
1158    epoch: u64,
1159    flush_uuid: &str,
1160) -> LeanResult<(ObjectMeta, Vec<String>, Pointer)> {
1161    let have: std::collections::HashSet<&str> = prev.iter().map(|r| r.addr.as_str()).collect();
1162
1163    let stamps = GenerationStamps {
1164        generation: doc.seq,
1165        epoch,
1166        flush_uuid: flush_uuid.to_string(),
1167        boundary_source: doc.boundary_source.clone(),
1168        posix: None,
1169    };
1170
1171    for (r, body) in chunks {
1172        if have.contains(r.addr.as_str()) {
1173            continue;
1174        }
1175        let Some(body) = body else {
1176            return Err(LeanError::State(format!(
1177                "chunk {} is not in the list this publish replaces and was never encoded",
1178                r.addr
1179            )));
1180        };
1181        // UNCONDITIONAL, and that is a correctness requirement rather
1182        // than laziness — `LeanChunkGCAdoptSkips` in the formal model
1183        // fails without it.
1184        //
1185        // This chunk is not in `prev`, so nothing references it yet. It
1186        // may nonetheless already EXIST: a crashed publish of ours left
1187        // it, and content addressing means our retry produces the same
1188        // key. Adopting it by reference alone would make an old object
1189        // live without making it LOOK live, and the orphan sweep reads
1190        // age — so the sweep would collect it as the orphan it still
1191        // appears to be, between this call and our pointer CAS.
1192        // Rewriting refreshes the age the sweep reads. The bytes are
1193        // identical either way, so the write is idempotent; the cost
1194        // falls only on adopted orphans, which are rare by
1195        // construction, and chunks already in `prev` are skipped above.
1196        let key = cfg.chunk_key(&r.addr);
1197        let crc = crc64_nvme(body);
1198        store.put_whole(&key, body.clone().into(), &PutCondition::Unconditional, &stamps, crc).await?;
1199        // This writer's next load of its own document fetches nothing it
1200        // just wrote (latency plan lever A). Kept whether or not the CAS
1201        // below lands: a body is right for its address either way, and a
1202        // re-merge after a lost race mostly re-addresses to these.
1203        if let Some(c) = cfg.chunk_cache.as_deref() {
1204            c.put(&r.addr, body);
1205        }
1206    }
1207
1208    // The tombstones, one object beside the chunks (rewritten like an
1209    // adopted chunk: the sweep reads age, and the bytes are identical).
1210    let tombstones = if doc.tombstones.is_empty() {
1211        None
1212    } else {
1213        let (addr, body) = super::chunk::encode_tombstones(doc.tombstones)?;
1214        let crc = crc64_nvme(&body);
1215        if let Some(c) = cfg.chunk_cache.as_deref() {
1216            c.put(&addr, &body);
1217        }
1218        store.put_whole(&cfg.chunk_key(&addr), body.into(), &PutCondition::Unconditional, &stamps, crc).await?;
1219        Some(addr)
1220    };
1221    let pointer = Pointer {
1222        seq: doc.seq,
1223        entries_key: None,
1224        entries_seq: None,
1225        chunks: Some(chunks.iter().map(|(r, _)| r.clone()).collect()),
1226        sole_writer: doc.sole_writer,
1227        boundary_source: doc.boundary_source.clone(),
1228        epoch,
1229        tombstones: tombstones.clone(),
1230    };
1231    let meta = put_pointer(store, cfg, &pointer, expected, &stamps).await?;
1232    let mut refs: Vec<String> = pointer.chunks.iter().flatten().map(|r| r.addr.clone()).collect();
1233    refs.extend(tombstones);
1234    Ok((meta, refs, pointer))
1235}
1236
1237/// What a commit retired (M1): the handles its document no longer cites
1238/// and the chunk objects its pointer no longer references. One immutable
1239/// log object per commit, under `retired/`, keyed by the unix second it
1240/// was written — the retirement's clock. The sweeps spare everything a
1241/// log younger than `retire_grace_secs` names; a writer reaps the older
1242/// ones (`untracked.rs`).
1243#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1244pub struct RetireLog {
1245    pub at_unix: u64,
1246    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1247    pub handles: Vec<String>,
1248    /// Chunk ADDRESSES (`LeanConfig::chunk_key` names the object).
1249    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1250    pub chunks: Vec<String>,
1251}
1252
1253pub fn retired_prefix(cfg: &LeanConfig) -> String {
1254    format!("{}/{}/retired/", cfg.prefix, super::LEAN_DIR)
1255}
1256
1257/// The unix second a retire log's KEY carries (`<at:020>-...`): a listing
1258/// dates every log with no GET.
1259pub fn retire_log_at(cfg: &LeanConfig, key: &str) -> Option<u64> {
1260    key.strip_prefix(&retired_prefix(cfg))?.get(..20)?.parse().ok()
1261}
1262
1263/// A commit, and the log of what it retired, written right after the CAS.
1264/// A crash between the two leaves those handles and chunks to the sweeps'
1265/// write-age rule — the shape before the retire age — and nothing worse.
1266/// The log is best effort for the same reason: a failure to write it costs
1267/// a lagging reader a re-resolve, never the commit.
1268#[allow(clippy::too_many_arguments)]
1269pub async fn cas_write_retiring(
1270    store: &dyn ObjectStore,
1271    cfg: &LeanConfig,
1272    m: &LeanManifest,
1273    before: Option<&LeanManifest>,
1274    expected: Option<&ManifestHandle>,
1275    epoch: u64,
1276    flush_uuid: &str,
1277    boundary_source: Option<&str>,
1278) -> LeanResult<ObjectMeta> {
1279    let (meta, refs) = cas_write_refs(store, cfg, m, expected, epoch, flush_uuid, boundary_source).await?;
1280    let still: std::collections::HashSet<&str> = m.entries.values().map(|e| e.key.as_str()).collect();
1281    let handles: Vec<String> = before
1282        .map(|b| b.entries.values().map(|e| e.key.clone()).filter(|k| !still.contains(k.as_str())).collect())
1283        .unwrap_or_default();
1284    log_retired(store, cfg, m.seq, epoch, flush_uuid, handles, refs.as_deref(), expected).await;
1285    Ok(meta)
1286}
1287
1288/// Lever G's commit (latency plan §12): publish the chunk list a view's
1289/// re-chunk made (`docview::DocView::rechunk`), and log what it retired.
1290/// `retired_handles` is the caller's: the keys of the document it merged
1291/// onto that the published one no longer cites, as `cas_write_retiring`
1292/// computes them over both whole documents.
1293#[allow(clippy::too_many_arguments)]
1294pub async fn cas_write_view(
1295    store: &dyn ObjectStore,
1296    cfg: &LeanConfig,
1297    chunks: &[(super::chunk::ChunkRef, Option<Vec<u8>>)],
1298    doc: &ChunkedDoc<'_>,
1299    expected: &ManifestHandle,
1300    epoch: u64,
1301    flush_uuid: &str,
1302    retired_handles: Vec<String>,
1303) -> LeanResult<ObjectMeta> {
1304    let (meta, refs, _) =
1305        publish_chunk_list(store, cfg, chunks, doc, Some(expected), &expected.prev_chunks, epoch, flush_uuid).await?;
1306    log_retired(store, cfg, doc.seq, epoch, flush_uuid, retired_handles, Some(&refs), Some(expected)).await;
1307    Ok(meta)
1308}
1309
1310/// The log of what a commit retired: `handles`, and the chunk objects the
1311/// replaced pointer named that `refs` (the new one's) does not.
1312#[allow(clippy::too_many_arguments)]
1313async fn log_retired(
1314    store: &dyn ObjectStore,
1315    cfg: &LeanConfig,
1316    seq: u64,
1317    epoch: u64,
1318    flush_uuid: &str,
1319    mut handles: Vec<String>,
1320    refs: Option<&[String]>,
1321    expected: Option<&ManifestHandle>,
1322) {
1323    handles.sort();
1324    handles.dedup();
1325    let mut chunks: Vec<String> = vec![];
1326    if let (Some(refs), Some(h)) = (refs, expected) {
1327        let now: std::collections::HashSet<&str> = refs.iter().map(|a| a.as_str()).collect();
1328        chunks = h
1329            .prev_chunks
1330            .iter()
1331            .map(|r| r.addr.clone())
1332            .chain(h.prev_tombstones.clone())
1333            .filter(|a| !now.contains(a.as_str()))
1334            .collect();
1335        chunks.sort();
1336        chunks.dedup();
1337    }
1338    if !handles.is_empty() || !chunks.is_empty() {
1339        let at = super::now_unix();
1340        let log = RetireLog { at_unix: at, handles, chunks };
1341        let key = format!("{}{at:020}-{flush_uuid}-{}.json", retired_prefix(cfg), uuid::Uuid::new_v4());
1342        let body = match serde_json::to_vec(&log) {
1343            Ok(b) => b,
1344            Err(e) => {
1345                eprintln!("flint-sync: could not encode what seq {seq} retired ({e}); the sweeps judge it by write age");
1346                return;
1347            }
1348        };
1349        let crc = crc64_nvme(&body);
1350        let stamps = GenerationStamps {
1351            generation: seq,
1352            epoch,
1353            flush_uuid: flush_uuid.to_string(),
1354            boundary_source: None,
1355            posix: None,
1356        };
1357        if let Err(e) = store.put_whole(&key, body.into(), &PutCondition::IfNoneMatchAny, &stamps, crc).await {
1358            eprintln!("flint-sync: could not log what seq {seq} retired ({e}); the sweeps judge it by write age");
1359        }
1360    }
1361}
1362
1363/// CAS the pointer. Split out because a takeover rotation writes ONLY
1364/// this — no entries object, no multi-MB anything.
1365async fn put_pointer(
1366    store: &dyn ObjectStore,
1367    cfg: &LeanConfig,
1368    pointer: &Pointer,
1369    expected: Option<&ManifestHandle>,
1370    stamps: &GenerationStamps,
1371) -> LeanResult<ObjectMeta> {
1372    let body = serde_json::to_vec(pointer)
1373        .map_err(|e| super::LeanError::State(format!("pointer encode: {e}")))?;
1374    let crc = crc64_nvme(&body);
1375    // A LEGACY handle is a migration: the writer read the old single
1376    // object and the pointer does not exist yet, so the precondition is
1377    // "no pointer", not "the pointer I read". A concurrent migration
1378    // therefore loses here and retries against the layout that won,
1379    // instead of both of them believing they installed it.
1380    let cond = match expected {
1381        Some(h) if !h.legacy => PutCondition::IfMatch(h.etag.clone()),
1382        _ => PutCondition::IfNoneMatchAny,
1383    };
1384    let meta = store.put_whole(&cfg.current_key(), body.into(), &cond, stamps, crc).await?;
1385    if let Some(h) = expected.filter(|h| h.legacy) {
1386        // The workspace has moved. Poison the legacy key rather than
1387        // delete it — see REFUSAL_DOC. Conditional on the very object
1388        // this writer read, so a racing writer's view is never
1389        // clobbered, and best effort: the refusal doc is decorative to
1390        // every new reader, and the ONLY reader it speaks to is an old
1391        // binary that must refuse rather than re-seed.
1392        let bytes = REFUSAL_DOC.as_bytes().to_vec();
1393        let crc = crc64_nvme(&bytes);
1394        if let Err(e) = store
1395            .put_whole(
1396                &cfg.manifest_key(),
1397                bytes.into(),
1398                &PutCondition::IfMatch(h.etag.clone()),
1399                stamps,
1400                crc,
1401            )
1402            .await
1403        {
1404            eprintln!("flint-sync: could not poison the legacy manifest key after migration: {e}");
1405        }
1406    }
1407    Ok(meta)
1408}
1409
1410/// Bump the generation without touching the entries.
1411///
1412/// This is what a takeover does, and under the single-object layout it
1413/// was a multi-MB GET + PUT per claim that also moved the ETag every
1414/// other syncer's no-change early exit compares against — so one claim
1415/// cost every follower a full fetch and parse of a document in which
1416/// nothing had changed. Here it is one small CAS: `seq` moves, so an
1417/// outstanding pointer handle goes stale exactly as before, and
1418/// `entries_seq` does NOT, so a follower can see that the entries are
1419/// the ones it already has.
1420pub async fn rotate_for_takeover(
1421    store: &dyn ObjectStore,
1422    cfg: &LeanConfig,
1423    epoch: u64,
1424) -> LeanResult<Option<(LeanManifest, String)>> {
1425    for _ in 0..3 {
1426        // The legacy layout has no pointer to rotate: migrate by
1427        // rewriting the document once, then rotations are cheap forever.
1428        let Some(LoadedPointer { pointer: p, etag, .. }) = load_pointer(store, cfg).await? else {
1429            let Some(loaded) = load(store, cfg).await? else {
1430                return Ok(None);
1431            };
1432            let mut rotated = loaded.manifest.clone();
1433            rotated.seq += 1;
1434            let h = loaded.handle();
1435            match cas_write(store, cfg, &rotated, Some(&h), epoch, "takeover-rotation").await {
1436                Ok(meta) => return Ok(Some((rotated, meta.etag))),
1437                Err(super::LeanError::Store(StoreError::PreconditionFailed(_) | StoreError::Conflict(_))) => continue,
1438                Err(e) => return Err(e),
1439            }
1440        };
1441        let next = Pointer { seq: p.seq + 1, epoch, ..p.clone() };
1442        let stamps = GenerationStamps {
1443            generation: next.seq,
1444            epoch,
1445            flush_uuid: "takeover-rotation".to_string(),
1446            boundary_source: next.boundary_source.clone(),
1447            posix: None,
1448        };
1449        let h = ManifestHandle { etag, legacy: false, prev_chunks: Vec::new(), prev_tombstones: None };
1450        match put_pointer(store, cfg, &next, Some(&h), &stamps).await {
1451            Ok(meta) => {
1452                // The document the caller gets back is the standing one
1453                // with the rotated seq — the entries were not read and
1454                // did not need to be.
1455                let mut m = LeanManifest::default();
1456                m.seq = next.seq;
1457                m.sole_writer = next.sole_writer;
1458                m.boundary_source = next.boundary_source.clone();
1459                return Ok(Some((m, meta.etag)));
1460            }
1461            // S3 answers a conditional write racing another on the key with 409,
1462            // and a takeover whose acquire has LANDED must not fail on a lost race
1463            // (review 2026-09-18, H2).
1464            Err(super::LeanError::Store(StoreError::PreconditionFailed(_) | StoreError::Conflict(_))) => continue,
1465            Err(e) => return Err(e),
1466        }
1467    }
1468    Err(super::LeanError::State(
1469        "takeover rotation lost 3 CAS races — a live writer is still publishing".into(),
1470    ))
1471}
1472
1473/// How many superseded generations to keep behind the live one.
1474///
1475/// Not zero, and not one. A reader GETs the pointer and then GETs the
1476/// object it names, and those are two requests with a gap between them:
1477/// reaping the generation a reader has just resolved would turn a
1478/// perfectly ordinary read into a `NotFound`. A handful is a few hundred
1479/// KiB at any realistic entry count and buys that gap several publishes.
1480pub const KEEP_GENERATIONS: usize = 5;
1481
1482/// How long a generation NEWER than the live one is left alone before it
1483/// is treated as an orphan.
1484///
1485/// An object above the live generation is one of two things: a publish
1486/// that is in flight right now — its entries written, its pointer CAS
1487/// not yet landed — or the wreckage of one that died in that window. The
1488/// two are indistinguishable from a listing, and deleting the first
1489/// breaks a live publish, so age is the only honest discriminator. Well
1490/// past any plausible manifest PUT, including a 264 MiB one over a slow
1491/// link.
1492pub const ORPHAN_GRACE_SECS: u64 = 3600;
1493
1494/// Retire-age G's default (`LeanConfig::retire_grace_secs`): how long a
1495/// handle or chunk stays after the commit that stopped referencing it.
1496/// Past a large checkout's manifest load and a UI read's two GETs; a
1497/// reader that lags longer re-resolves the pointer, as it always has. It
1498/// costs storage: every version a commit replaced, kept this long.
1499pub const RETIRE_GRACE_SECS: u64 = 600;
1500
1501/// Delete superseded generation objects, and orphans left by a publish
1502/// that died between its entries PUT and its pointer CAS.
1503///
1504/// Immutable metadata that is never collected is a leak, and this one
1505/// grows by a whole manifest per publish.
1506///
1507/// The two cases are NOT symmetric, which the first cut of this got
1508/// wrong. BELOW the live generation, everything is superseded by
1509/// definition and the only question is how much slack to leave a reader
1510/// mid-resolve — that is `KEEP_GENERATIONS`. ABOVE it, an object may be
1511/// a publish still in flight, and a keep-window is exactly the wrong
1512/// tool: an orphan at a high seq sorts to the front and the window
1513/// protects it forever while costing a real generation its slot. Age
1514/// decides that case instead.
1515///
1516/// Best effort and off the critical path: a failure here costs storage,
1517/// never correctness, so it warns rather than failing a publish.
1518pub async fn sweep_generations(store: &dyn ObjectStore, cfg: &LeanConfig) -> LeanResult<usize> {
1519    let Some(lp) = load_pointer(store, cfg).await? else {
1520        return Ok(0);
1521    };
1522    // A chunked pointer names no generation object at all, so there is
1523    // nothing here to keep a window of. Chunks are shared across
1524    // generations and are collected by a different rule entirely
1525    // (chunked design §8.1) — sweeping them by "not named by the live
1526    // pointer" would delete history the window exists to preserve.
1527    let Some(live) = lp.pointer.entries_key else {
1528        // The workspace has MOVED to the chunk layout, so no pointer
1529        // names a generation object any more and every one of them is
1530        // superseded. Collect them past the grace rather than leaking
1531        // the whole pre-migration history forever — a reader still
1532        // resolving one revalidates and restarts (§8.2), and the grace
1533        // covers the window in which it might not have noticed yet.
1534        let prefix = format!("{}/{}/manifests/", cfg.prefix, super::LEAN_DIR);
1535        let now = super::now_unix();
1536        let mut removed = 0;
1537        for o in store.list(&prefix).await? {
1538            let age = o.last_modified_unix.map(|t| now.saturating_sub(t));
1539            if !age.is_some_and(|a| a >= cfg.orphan_grace_secs) {
1540                continue;
1541            }
1542            match store.delete(&o.key).await {
1543                Ok(()) => removed += 1,
1544                Err(e) => eprintln!("flint-sync: could not reap migrated generation {}: {e}", o.key),
1545            }
1546        }
1547        return Ok(removed);
1548    };
1549    let prefix = format!("{}/{}/manifests/", cfg.prefix, super::LEAN_DIR);
1550    let mut listed = store.list(&prefix).await?;
1551    // The key is `<seq:020>-<uuid>`, so lexical order IS generation
1552    // order and a plain sort is enough — the zero padding is doing this
1553    // job, which is why it is there.
1554    listed.sort_by(|a, b| a.key.cmp(&b.key));
1555    let now = super::now_unix();
1556    let live_ix = listed.iter().position(|o| o.key == live);
1557
1558    let mut doomed: Vec<String> = Vec::new();
1559    for (i, o) in listed.iter().enumerate() {
1560        if o.key == live {
1561            continue;
1562        }
1563        match live_ix {
1564            // Newer than the live pointer: in flight, or wreckage.
1565            Some(ix) if i > ix => {
1566                let age = o.last_modified_unix.map(|t| now.saturating_sub(t));
1567                // No timestamp ⇒ leave it. A store that cannot date its
1568                // objects gets a leak, not a deleted live publish.
1569                if age.is_some_and(|a| a > ORPHAN_GRACE_SECS) {
1570                    doomed.push(o.key.clone());
1571                }
1572            }
1573            // Older than the live pointer: superseded. Keep a window — by
1574            // count, and (M1) until the generation that superseded it is
1575            // `retire_grace_secs` old: at one UI save a second, the count
1576            // alone is seconds. No date on the successor ⇒ keep it.
1577            Some(ix) => {
1578                let superseded_for = listed[i + 1].last_modified_unix.map(|t| now.saturating_sub(t));
1579                if ix.saturating_sub(i) > KEEP_GENERATIONS
1580                    && superseded_for.is_some_and(|a| a >= cfg.retire_grace_secs)
1581                {
1582                    doomed.push(o.key.clone());
1583                }
1584            }
1585            // The pointer names an object that is not in the listing.
1586            // `load` already refuses that workspace; sweep nothing.
1587            None => return Ok(0),
1588        }
1589    }
1590    let mut removed = 0;
1591    for k in doomed {
1592        match store.delete(&k).await {
1593            Ok(()) => removed += 1,
1594            Err(e) => eprintln!("flint-sync: could not reap superseded generation {k}: {e}"),
1595        }
1596    }
1597    Ok(removed)
1598}
1599
1600/// Collect chunk objects that no live pointer references.
1601///
1602/// Built to `lean/formal/LeanChunkGC.tla`, which refuted the ordering
1603/// rule the design originally prescribed. Four things carry the safety
1604/// here and each has a mutation config that breaks it alone:
1605///
1606/// 1. **The reference set must not predate a CAS the delete follows.**
1607///    S3 cannot make the read and the delete atomic, so this fences
1608///    instead: the pointer's ETag is read before the listing and again
1609///    after, and the pass ABORTS if it moved. A publish landed, and we
1610///    no longer know whether it references a candidate.
1611/// 2. **A grace.** A chunk written and not yet referenced would
1612///    otherwise be collected out from under its own publish.
1613/// 3. **The grace outlives the longest publish** — a timing
1614///    assumption, which is why `orphan_grace_secs` is config and why
1615///    this comment names it rather than leaving it implied.
1616/// 4. **Adoption rewrites what it adopts** (`cas_write_chunked`), so
1617///    any chunk a concurrent publish could reference is FRESH and the
1618///    grace covers it. Without that rule an adopted orphan is old, and
1619///    the age this reads is a lie about whether it is in use.
1620///
1621/// Deliberately no history window, unlike `sweep_generations`. That
1622/// window existed partly so a reader mid-resolve could finish; readers
1623/// now revalidate against the pointer and restart onto the current
1624/// generation (§8.2), which removes the reason. Adding one back means
1625/// retaining pointer SNAPSHOTS so their chunk sets are enumerable
1626/// (§9) — chunks are shared, so "what the live pointer does not name"
1627/// is the only reference set available without them.
1628pub async fn sweep_chunks(store: &dyn ObjectStore, cfg: &LeanConfig) -> LeanResult<usize> {
1629    sweep_chunks_sparing(store, cfg, &std::collections::HashSet::new()).await
1630}
1631
1632/// `sweep_chunks`, sparing the chunk KEYS a young retire log names (M1):
1633/// a chunk a commit superseded less than `retire_grace_secs` ago, however
1634/// long ago it was written. A reader mid-load of the previous pointer
1635/// finds it.
1636pub async fn sweep_chunks_sparing(
1637    store: &dyn ObjectStore,
1638    cfg: &LeanConfig,
1639    spared: &std::collections::HashSet<String>,
1640) -> LeanResult<usize> {
1641    let Some(before) = load_pointer(store, cfg).await? else {
1642        return Ok(0);
1643    };
1644    let mut refs: std::collections::HashSet<String> = match before.pointer.entries()? {
1645        Entries::Chunked(c) => c.iter().map(|r| r.addr.clone()).collect(),
1646        // The single-object layout has no chunks; `sweep_generations`
1647        // owns it, and running both over one workspace would have each
1648        // reasoning about objects the other manages.
1649        Entries::Single { .. } => return Ok(0),
1650    };
1651    // The tombstone object lives beside the chunks and is referenced
1652    // the same way.
1653    if let Some(t) = &before.pointer.tombstones {
1654        refs.insert(t.clone());
1655    }
1656
1657    let prefix = format!("{}/{}/chunks/", cfg.prefix, super::LEAN_DIR);
1658    let listed = store.list(&prefix).await?;
1659
1660    // THE FENCE (rule 1). Nothing published across the reference read
1661    // and the listing, so the candidates and the references describe
1662    // the same moment. A publish that lands after this still cannot
1663    // lose a chunk: everything it references is either already in
1664    // `refs` or was just written or rewritten, and rule 4 guarantees
1665    // the latter are fresh.
1666    let after = load_pointer(store, cfg).await?;
1667    if after.map(|p| p.etag) != Some(before.etag) {
1668        return Ok(0);
1669    }
1670
1671    let now = super::now_unix();
1672    let mut removed = 0;
1673    for o in listed {
1674        let Some(addr) = o.key.rsplit('/').next() else { continue };
1675        if refs.contains(addr) || spared.contains(&o.key) {
1676            continue;
1677        }
1678        // A candidate the LISTING already shows inside the grace cannot be
1679        // past it on a HEAD taken now: a rewrite only makes an object
1680        // younger, so the listing's age bounds the HEAD's from above.
1681        // Skipping it keeps a chunk longer — a leak, never a deleted live
1682        // chunk — and every deletion still rests on the HEAD below. Without
1683        // this every commit HEADed every chunk superseded within the grace,
1684        // one at a time inside the fence: 700 ms of a 1 s hold after five
1685        // minutes of six writers (contention drill), growing for the hour.
1686        if let Some(listed) = o.last_modified_unix {
1687            if now.saturating_sub(listed) < cfg.orphan_grace_secs {
1688                continue;
1689            }
1690        }
1691        // The age must come from a HEAD taken NOW, never from the
1692        // listing above. That listing predates the fence, and rule 4
1693        // (adoption REWRITES what it adopts) works by refreshing a
1694        // chunk's age — so judging by the listing's timestamp makes
1695        // exactly the refresh that rule exists to produce invisible,
1696        // and the reaper deletes a chunk an in-flight publish is about
1697        // to name. The model computed this against the store's state at
1698        // DELETE time; reading it from a pre-fence snapshot was an
1699        // implementation that did not match the rule it was built to.
1700        //
1701        // One HEAD per CANDIDATE past the grace by the listing, not per
1702        // chunk: a busy workspace supersedes chunks on every publish.
1703        //
1704        // Gone already ⇒ nothing to do. Any other HEAD failure, or no
1705        // timestamp ⇒ leave it: a store that cannot date its objects
1706        // gets a leak, not a deleted live chunk, the same rule
1707        // `sweep_generations` follows and for the same reason.
1708        let fresh = match store.head(&o.key).await {
1709            Ok(m) => m.last_modified_unix,
1710            Err(StoreError::NotFound(_)) => continue,
1711            Err(e) => {
1712                eprintln!("flint-sync: could not date orphan chunk {} ({e}); leaving it", o.key);
1713                continue;
1714            }
1715        };
1716        // `>=`, not `>`: the config value is a DURATION the object must
1717        // have survived, so `orphan_grace_secs = 0` has to mean "no
1718        // grace". With `>` it would silently mean "collect nothing
1719        // written this second", which is most of them — a knob whose
1720        // zero disables the thing it configures is a trap.
1721        let age = fresh.map(|t| now.saturating_sub(t));
1722        if !age.is_some_and(|a| a >= cfg.orphan_grace_secs) {
1723            continue;
1724        }
1725        match store.delete(&o.key).await {
1726            Ok(()) => removed += 1,
1727            Err(e) => eprintln!("flint-sync: could not reap orphan chunk {}: {e}", o.key),
1728        }
1729    }
1730    Ok(removed)
1731}
1732
1733/// The three-way merge (the model's `inst`). Starts from THEIRS so
1734/// foreign entries survive by construction; applies my upserts; applies
1735/// my deletes only where theirs is unchanged since my merge base.
1736///
1737/// **`mine_upserts` and `mine_deletes` must be DISJOINT**, and that is
1738/// the caller's job, not this function's: which one wins depends on
1739/// which the tree saw LAST, and neither set carries that. The fused
1740/// barrier gets it free (both are computed from one scan). Resolving an
1741/// overlap here —
1742/// in either direction — cites a deleted file half the time and
1743/// amputates a live one the other half. TLC found both halves.
1744/// Returns (as `Merged`) the merged document, the foreign entries a consume must
1745/// integrate next (present in theirs, changed vs base, not mine), each
1746/// declared delete's outcome, and the entries MINE PUBLISHES OVER: theirs' version at a path this barrier
1747/// uploaded, changed since the merge base. The slot's 412 used to catch
1748/// those at the PUT; under handles the merge is the one place they show,
1749/// and the caller decides which it never integrated and must surface
1750/// (design 2026-09-19, R7).
1751pub fn merge(
1752    base: &BTreeMap<String, String>,
1753    theirs: &LeanManifest,
1754    mine_upserts: &BTreeMap<String, LeanEntry>,
1755    mine_deletes: &BTreeSet<String>,
1756    parked: &BTreeSet<String>,
1757) -> Merged {
1758    let base_of = |p: &str| base.get(p).map(|b| b.as_str());
1759    let at: BTreeMap<String, LeanEntry> = mine_upserts
1760        .keys()
1761        .chain(mine_deletes.iter())
1762        .filter_map(|p| theirs.entries.get(p).map(|e| (p.clone(), e.clone())))
1763        .collect();
1764    let c = merge_changes(&base_of, &at, &theirs.tombstones, theirs.seq, mine_upserts, mine_deletes, parked);
1765    let mut foreign: Vec<(String, LeanEntry)> = vec![];
1766    for (p, e) in &theirs.entries {
1767        let changed = base.get(p).map(|b| b != &e.etag).unwrap_or(true);
1768        if changed && !mine_upserts.contains_key(p) && !parked.contains(p) {
1769            foreign.push((p.clone(), e.clone()));
1770        }
1771    }
1772    let mut merged = theirs.clone();
1773    merged.seq = c.seq;
1774    // Same discipline, and for a sharper reason: inheriting it would
1775    // let a workspace that once published as a mirror keep refusing
1776    // adopts forever, and NOT inheriting it would let a mirror silently
1777    // stop being one the first time anything else wrote a manifest.
1778    // The installing pass sets it from its own config.
1779    merged.sole_writer = false;
1780    merged.boundary_source = None;
1781    for (p, v) in &c.overlay {
1782        match v {
1783            Some(e) => {
1784                merged.entries.insert(p.clone(), e.clone());
1785            }
1786            None => {
1787                merged.entries.remove(p);
1788            }
1789        }
1790    }
1791    merged.tombstones = c.tombstones;
1792    Merged {
1793        doc: merged,
1794        foreign,
1795        overridden: c.overridden,
1796        deletes: c.deletes,
1797        recreated: c.recreated,
1798        over_theirs: c.over_theirs,
1799    }
1800}
1801
1802/// What `merge` decides for the paths a barrier changes (lever G, latency
1803/// plan §12): everything but the foreign set, which is about every OTHER
1804/// path. `merge` is this over the whole document plus that set, so a path
1805/// cannot be decided two ways.
1806pub struct MergedChanges {
1807    /// The merged document's generation.
1808    pub seq: u64,
1809    /// Each changed path's entry in the merged document: `Some` cited (an
1810    /// upsert), `None` no longer cited (a delete that applied to a cited
1811    /// path). Every other path is theirs, as it stands.
1812    pub overlay: BTreeMap<String, Option<LeanEntry>>,
1813    /// The merged document's tombstones, whole.
1814    pub tombstones: BTreeMap<String, Tombstone>,
1815    pub overridden: Vec<(String, LeanEntry)>,
1816    pub deletes: BTreeMap<String, DeleteOutcome>,
1817    pub recreated: Vec<(String, String)>,
1818    pub over_theirs: Vec<(String, LeanEntry)>,
1819}
1820
1821/// `merge`'s per-path decisions. `base(p)` is the merge base's etag at
1822/// `p`; `theirs_at` is theirs' entry at every path of `mine_upserts` and
1823/// `mine_deletes` it cites (a path it lacks is not cited).
1824pub fn merge_changes<'b>(
1825    base: &dyn Fn(&str) -> Option<&'b str>,
1826    theirs_at: &BTreeMap<String, LeanEntry>,
1827    theirs_tombstones: &BTreeMap<String, Tombstone>,
1828    theirs_seq: u64,
1829    mine_upserts: &BTreeMap<String, LeanEntry>,
1830    mine_deletes: &BTreeSet<String>,
1831    parked: &BTreeSet<String>,
1832) -> MergedChanges {
1833    let seq = theirs_seq + 1;
1834    let mut tombstones = theirs_tombstones.clone();
1835    let mut overlay: BTreeMap<String, Option<LeanEntry>> = BTreeMap::new();
1836    // The DELETES mine publishes over: a path mine uploads that the merge
1837    // base cited and theirs no longer does. Named by the version the
1838    // delete removed — theirs' tombstone, else the base's citation.
1839    let recreated: Vec<(String, String)> = mine_upserts
1840        .keys()
1841        .filter(|p| !theirs_at.contains_key(*p))
1842        .filter_map(|p| {
1843            let base_etag = base(p)?;
1844            let deleted = theirs_tombstones.get(p).map(|t| t.etag.clone()).unwrap_or_else(|| base_etag.to_string());
1845            Some((p.clone(), deleted))
1846        })
1847        .collect();
1848    // Theirs' version at a path mine uploads, changed since the merge base
1849    // (R7), in path order.
1850    let overridden: Vec<(String, LeanEntry)> = mine_upserts
1851        .keys()
1852        .filter_map(|p| theirs_at.get(p).map(|e| (p, e)))
1853        .filter(|(p, e)| base(p).map(|b| b != e.etag).unwrap_or(true))
1854        .map(|(p, e)| (p.clone(), e.clone()))
1855        .collect();
1856    for (p, e) in mine_upserts {
1857        overlay.insert(p.clone(), Some(e.clone()));
1858        // A path cited again has no delete to remember.
1859        tombstones.remove(p);
1860    }
1861    let mut deletes = BTreeMap::new();
1862    let mut over_theirs: Vec<(String, LeanEntry)> = vec![];
1863    for p in mine_deletes {
1864        let outcome = delete_outcome_with(base(p), theirs_at.get(p), parked.contains(p));
1865        deletes.insert(p.clone(), outcome);
1866        if outcome == DeleteOutcome::OverTheirs {
1867            if let Some(e) = theirs_at.get(p) {
1868                over_theirs.push((p.clone(), e.clone()));
1869            }
1870        }
1871        if outcome != DeleteOutcome::Parked {
1872            // What the merged document cites at `p` so far: mine's upsert,
1873            // else theirs.
1874            let cited = match overlay.get(p) {
1875                Some(v) => v.clone(),
1876                None => theirs_at.get(p).cloned(),
1877            };
1878            if let Some(retired) = cited {
1879                overlay.insert(p.clone(), None);
1880                // H1e: the document says what this delete retired.
1881                tombstones.insert(p.clone(), Tombstone { etag: retired.etag, seq });
1882            }
1883        }
1884    }
1885    tombstones.retain(|_, t| seq.saturating_sub(t.seq) <= TOMBSTONE_KEEP_SEQS);
1886    MergedChanges { seq, overlay, tombstones, overridden, deletes, recreated, over_theirs }
1887}
1888
1889/// What `merge` returns: the document, and what it decided per path.
1890pub struct Merged {
1891    pub doc: LeanManifest,
1892    /// Entries a consume must integrate next: present in theirs, changed
1893    /// vs base, not mine.
1894    pub foreign: Vec<(String, LeanEntry)>,
1895    /// Theirs' version at a path this barrier uploaded, changed since the
1896    /// merge base (R7).
1897    pub overridden: Vec<(String, LeanEntry)>,
1898    /// Every declared delete, and what the merge did with it.
1899    pub deletes: BTreeMap<String, DeleteOutcome>,
1900    /// The deletes mine publishes over: `(path, the deleted version's
1901    /// etag)` for a path mine uploads that the merge base cited and theirs
1902    /// deleted. Mine wins; the caller records each (the user's rule: a
1903    /// delete over a dirty path lands, with a record).
1904    pub recreated: Vec<(String, String)>,
1905    /// The versions mine DELETES over: theirs at a path this barrier
1906    /// deletes, changed since the merge base (M3, P1-lite). Mine wins; the
1907    /// caller preserves and records each before the collector takes it.
1908    pub over_theirs: Vec<(String, LeanEntry)>,
1909}
1910
1911/// What the merge does with one of my deletes.
1912#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1913pub enum DeleteOutcome {
1914    /// Theirs is unchanged since my merge base, or already gone: the
1915    /// citation goes.
1916    Applied,
1917    /// Theirs moved off my merge base to a version this tree never had
1918    /// (M3, P1-lite): the delete still applies — mine wins — and theirs is
1919    /// preserved and recorded (`Merged::over_theirs`). Before P1-lite theirs
1920    /// stayed cited (outranked), and under a derived owed set that never
1921    /// settles: the tree lacks the path, the document cites theirs, the
1922    /// path is dirty so nothing is owed, and every barrier repeats.
1923    OverTheirs,
1924    /// Withheld this barrier.
1925    Parked,
1926}
1927
1928/// The ONE place a delete's outcome is decided. `merge` applies it, and
1929/// a caller that must know whether an install moves a path off its
1930/// citation asks this with the same base — never re-derives it. Re-deriving
1931/// it is how the repair rule came to read a different base from the merge
1932/// (M5, L-124).
1933pub fn delete_outcome(
1934    base: &BTreeMap<String, String>,
1935    theirs: &LeanManifest,
1936    p: &str,
1937    parked: &BTreeSet<String>,
1938) -> DeleteOutcome {
1939    delete_outcome_with(base.get(p).map(|b| b.as_str()), theirs.entries.get(p), parked.contains(p))
1940}
1941
1942/// [`delete_outcome`] from the two things it reads: the merge base's etag
1943/// at the path, and theirs' entry there.
1944pub fn delete_outcome_with(base: Option<&str>, theirs: Option<&LeanEntry>, parked: bool) -> DeleteOutcome {
1945    if parked {
1946        return DeleteOutcome::Parked;
1947    }
1948    match (theirs, base) {
1949        (Some(e), Some(b)) if e.etag == b => DeleteOutcome::Applied,
1950        // Already deleted by someone else: nothing is left to cite.
1951        (None, _) => DeleteOutcome::Applied,
1952        // Changed since base, or present in theirs and not in base.
1953        (Some(_), _) => DeleteOutcome::OverTheirs,
1954    }
1955}
1956
1957